Skip to main content

brink_runtime/output/
mod.rs

1//! Output buffer with glue handling and deferred line resolution.
2
3use core::mem;
4
5use alloc::collections::BTreeMap;
6use alloc::string::String;
7#[cfg(test)]
8use alloc::string::ToString;
9use alloc::vec;
10use alloc::vec::Vec;
11
12use brink_format::{
13    LineContent, LineEntry, LinePart, PluralCategory, PluralResolver, SelectKey, Value,
14};
15
16use crate::program::Program;
17use crate::value_ops;
18
19mod completion;
20mod consume;
21mod fragment;
22
23use completion::LineCompletion;
24
25pub use fragment::{Fragment, FragmentRef, Fragments};
26
27/// A part of accumulated output.
28///
29/// Output parts are structural references that resolve at read time against
30/// the current line tables and plural resolver. This enables locale-hot-swap:
31/// the same transcript can be re-rendered in different languages without
32/// re-executing the story.
33///
34/// `PartialEq` (issue #746): structural equality over the part's own fields
35/// — used by the `.brkt` transcript round-trip law
36/// (`brink-runtime/tests/law_transcript_roundtrip.rs`) to assert decoded
37/// parts equal the originals. Every field type already implements it
38/// (`Value`'s hand-written impl, `LineFlags`'s derive).
39#[derive(Debug, Clone, PartialEq)]
40pub enum OutputPart {
41    /// Eagerly-resolved text. Not produced by the VM in production —
42    /// used in tests and available for external transcript construction.
43    Text(String),
44    /// Deferred line reference — resolved at read time against the
45    /// current line tables and plural resolver.
46    LineRef {
47        container_idx: u32,
48        line_idx: u16,
49        slots: Vec<Value>,
50        flags: brink_format::LineFlags,
51    },
52    /// Deferred value — stringified at read time.
53    ValueRef(Value),
54    Newline,
55    /// Word break — renders as a single space between content parts.
56    Spring,
57    Glue,
58    /// Marks the start of a captured region (string eval, tag, or function call).
59    Checkpoint,
60    /// A tag associated with the current line of output.
61    Tag(String),
62    /// One field of an `attach = StructName` convention handler's return
63    /// value, merged into the run currently open (issue #2108,
64    /// `docs/decision-log.md` 2026-08-03 "The element output model:
65    /// attachment is block-level metadata, delivery is per-line"). Embedded
66    /// in the SAME append-only stream as `Tag`, rather than mutated on
67    /// `Flow` directly, for the identical reason tags are: the output
68    /// buffer defers a `Newline`'s commitment until later content proves no
69    /// `Glue` reaches back over it (`OutputBuffer::has_completed_line`'s own
70    /// doc), so by the time a line is finally drained the VM may already
71    /// have stepped past several MORE opcodes (including a later run's own
72    /// `ElementAttach`/`ElementAttachEnd`). Reading a live, continuously-
73    /// mutated `Flow` field at drain time would misattribute a LATER run's
74    /// data to an EARLIER, still-buffered line — embedding the merge as its
75    /// own transcript entry, at the exact point it actually happened,
76    /// avoids that entirely: `resolve_lines_annotated` rebuilds the
77    /// correct per-line snapshot by walking the stream in order, the same
78    /// way it already does for `Tag`.
79    ///
80    /// Unlike `Tag` (which resets every line), this ACCUMULATES across
81    /// multiple lines until a matching [`Self::ElementAttachEnd`] closes the
82    /// run — ruling item 4/5: "the run IS the block" and "every line in it
83    /// carries a copy."
84    ///
85    /// **Not part of the persisted `.brkt` format** (`crate::transcript`'s
86    /// `is_persisted`) — like [`Self::Checkpoint`], this is in-memory-only
87    /// bookkeeping. Issue #2108 is explicitly scoped to "the in-memory half"
88    /// (see its own tracked follow-up on save/resume); a transcript replayed
89    /// from a `.brkt` file loses element attachment, matching that scope.
90    ElementAttach(String, String),
91    /// Closes the run an [`Self::ElementAttach`] opened, clearing the
92    /// accumulated data so content after this point is never misattributed
93    /// to a run it wasn't part of. See [`Self::ElementAttach`]'s doc for why
94    /// this lives in the transcript stream rather than on `Flow`, and for
95    /// its non-persisted status.
96    ElementAttachEnd,
97}
98
99impl OutputPart {
100    /// Resolve this output part to its text representation.
101    ///
102    /// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
103    /// using the provided program, line tables, and plural resolver.
104    /// Structural parts (`Newline`, `Spring`, `Glue`, `Checkpoint`, `Tag`)
105    /// resolve to empty string — they are handled by the resolution pipeline.
106    pub fn resolve(
107        &self,
108        program: &Program,
109        line_tables: &[Vec<LineEntry>],
110        resolver: Option<&dyn PluralResolver>,
111    ) -> String {
112        resolve_part(self, program, line_tables, resolver, &Fragments::default())
113    }
114
115    /// Returns true if this part represents non-whitespace text content.
116    fn is_content(&self) -> bool {
117        match self {
118            Self::Text(s) => !s.trim().is_empty(),
119            Self::LineRef { flags, .. } => {
120                !flags.contains(brink_format::LineFlags::ALL_WS)
121                    && !flags.contains(brink_format::LineFlags::EMPTY)
122            }
123            // B4 (`docs/stdlib-spec.md` §1.6b): a final-`None` value at the
124            // display boundary resolves to the empty string (see
125            // `value_ops::stringify_display`) — it must not count as
126            // content for leading-newline/glue suppression, matching how
127            // an eagerly-dropped `Value::Null` never reaches the
128            // transcript at all (`push_value_ref`, below). Unlike `Null`,
129            // a `None` value IS retained in the transcript (traceability
130            // is a §1.6b rider) — only its content-ness for whitespace
131            // bookkeeping is suppressed here.
132            Self::ValueRef(Value::OptionVal(None)) => false,
133            Self::ValueRef(_) => true,
134            _ => false,
135        }
136    }
137
138    /// Issue #3533: does this part render as something other than
139    /// whitespace? [`Self::is_content`] mirrors ink's
140    /// `outputStreamContainsContent`, where an empty `""` string still
141    /// counts (it lets the line's own newline through); this mirrors what
142    /// ink's newline lookahead treats as *extending* a line — a blank
143    /// `ValueRef` (`""`, `" "`, an empty list) never commits the line
144    /// before it, only visible text does.
145    fn is_visible(&self) -> bool {
146        match self {
147            Self::ValueRef(Value::String(s)) => !s.trim().is_empty(),
148            Self::ValueRef(Value::List(lv)) => !lv.items.is_empty(),
149            _ => self.is_content(),
150        }
151    }
152}
153
154/// Resolve a single output part to its text representation.
155///
156/// Thin owning wrapper over [`resolve_part_into`]; the production paths
157/// ([`resolve_parts`], `resolve_lines_annotated`) append straight into
158/// the line they are building instead, so a part's text is written once.
159fn resolve_part(
160    part: &OutputPart,
161    program: &Program,
162    line_tables: &[Vec<LineEntry>],
163    resolver: Option<&dyn PluralResolver>,
164    fragments: &Fragments,
165) -> String {
166    let mut out = String::new();
167    resolve_part_into(part, &mut out, program, line_tables, resolver, fragments);
168    out
169}
170
171/// Append a single output part's text to `out`.
172///
173/// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
174/// using the provided program, line tables, and plural resolver.
175/// Structural parts (`Newline`, `Spring`, `Glue`, `Checkpoint`, `Tag`)
176/// append nothing — they are handled by the resolution pipeline.
177///
178/// A plain literal reserves one byte beyond its own length: the common
179/// line is a single `Plain` entry, and the caller that hands the line out
180/// ([`OutputBuffer::take_first_line`]) terminates it with `'\n'`. Without
181/// the spare byte that push reallocates every such line (measured as one
182/// `realloc` per delivered line on `TheIntercept`, #3570 follow-up).
183fn resolve_part_into(
184    part: &OutputPart,
185    out: &mut String,
186    program: &Program,
187    line_tables: &[Vec<LineEntry>],
188    resolver: Option<&dyn PluralResolver>,
189    fragments: &Fragments,
190) {
191    match part {
192        OutputPart::Text(s) => {
193            out.reserve(s.len() + 1);
194            out.push_str(s);
195        }
196        OutputPart::LineRef {
197            container_idx,
198            line_idx,
199            slots,
200            ..
201        } => resolve_line_ref_into(
202            out,
203            program,
204            line_tables,
205            *container_idx,
206            *line_idx,
207            slots,
208            resolver,
209            fragments,
210        ),
211        OutputPart::ValueRef(Value::FragmentRef(idx)) => {
212            // Resolve the fragment's parts against current line tables.
213            if let Some(parts) = fragments.parts(*idx) {
214                let s = resolve_parts(parts, program, line_tables, resolver, fragments);
215                out.push_str(&s);
216            }
217        }
218        // B4 (`docs/stdlib-spec.md` §1.6b): the display boundary — a
219        // final-`None` value renders as nothing, not `"none"`. See
220        // `value_ops::stringify_display`'s doc comment for the full ruling.
221        OutputPart::ValueRef(val) => out.push_str(&value_ops::stringify_display(val, program)),
222        OutputPart::Newline
223        | OutputPart::Spring
224        | OutputPart::Glue
225        | OutputPart::Checkpoint
226        | OutputPart::Tag(_)
227        | OutputPart::ElementAttach(..)
228        | OutputPart::ElementAttachEnd => {}
229    }
230}
231
232/// Collapse whitespace where a freshly appended segment `out[start..]`
233/// meets the text before it: when both sides carry whitespace at the join,
234/// the segment's leading run goes. Returns whether the segment holds any
235/// non-whitespace — the "this part produced visible content" signal both
236/// line walkers use to clear `after_glue`.
237///
238/// Equivalent to the former `s.trim_start()`-then-`push_str` on an owned
239/// per-part `String`, without the per-part allocation.
240fn collapse_join(out: &mut String, start: usize) -> bool {
241    let segment = &out[start..];
242    if segment.is_empty() {
243        return false;
244    }
245    let non_blank = !segment.trim().is_empty();
246    if segment.starts_with(char::is_whitespace) && out[..start].ends_with(char::is_whitespace) {
247        let lead = segment.len() - segment.trim_start().len();
248        out.replace_range(start..start + lead, "");
249    }
250    non_blank
251}
252
253/// Resolve a `LineRef` to its text content.
254#[cfg(test)]
255fn resolve_line_ref(
256    program: &Program,
257    line_tables: &[Vec<LineEntry>],
258    container_idx: u32,
259    line_idx: u16,
260    slots: &[Value],
261    resolver: Option<&dyn PluralResolver>,
262    fragments: &Fragments,
263) -> String {
264    let mut out = String::new();
265    resolve_line_ref_into(
266        &mut out,
267        program,
268        line_tables,
269        container_idx,
270        line_idx,
271        slots,
272        resolver,
273        fragments,
274    );
275    out
276}
277
278/// Append a `LineRef`'s text content to `out`.
279#[expect(
280    clippy::too_many_arguments,
281    reason = "mirrors `resolve_line_ref`'s parameter list"
282)]
283fn resolve_line_ref_into(
284    out: &mut String,
285    program: &Program,
286    line_tables: &[Vec<LineEntry>],
287    container_idx: u32,
288    line_idx: u16,
289    slots: &[Value],
290    resolver: Option<&dyn PluralResolver>,
291    fragments: &Fragments,
292) {
293    let scope_idx = program.scope_table_idx(container_idx) as usize;
294    let lines = &line_tables[scope_idx];
295    let Some(entry) = lines.get(line_idx as usize) else {
296        return;
297    };
298
299    match &entry.content {
300        LineContent::Plain(s) => {
301            // See `resolve_part_into` for the spare byte.
302            out.reserve(s.len() + 1);
303            out.push_str(s);
304        }
305        LineContent::Template(parts) => {
306            resolve_line_parts_into(out, parts, program, line_tables, slots, resolver, fragments);
307        }
308    }
309}
310
311/// Append a sequence of `LinePart`s to `out`.
312///
313/// A span is presentational (§4.3) and the runtime's current public API
314/// (`Line::Text.text`) is flat text with no structured span surface yet
315/// (`docs/prose-dialect-spec.md` §7/§9.1: the `Step`/`Part` redesign that
316/// would carry `Part::Span` structure through to a consumer is still ⏳) —
317/// so a span resolves here to its children's concatenated text, tag name
318/// and attrs stripped, recursing through this same function. That is
319/// additive groundwork for the future structured surface, not a
320/// replacement of it: §4.4 explicitly wants "structural parts over
321/// byte-range offsets" once that surface lands.
322///
323/// Whitespace at part joins collapses exactly as it did when every part
324/// was its own `String`: an empty part is skipped, and a part starting
325/// with a space loses its leading whitespace when the template's text so
326/// far is empty or already ends in a space. "The template's text so far"
327/// is `out[base..start]` — the text this call appended, not whatever the
328/// caller had in `out` before it — so a nested span behaves like the fresh
329/// `String` it used to be.
330fn resolve_line_parts_into(
331    out: &mut String,
332    parts: &[LinePart],
333    program: &Program,
334    line_tables: &[Vec<LineEntry>],
335    slots: &[Value],
336    resolver: Option<&dyn PluralResolver>,
337    fragments: &Fragments,
338) {
339    let base = out.len();
340    for part in parts {
341        let start = out.len();
342        match part {
343            LinePart::Literal(s) => out.push_str(s),
344            LinePart::Slot(n) => match slots.get(*n as usize) {
345                Some(Value::FragmentRef(idx)) => {
346                    if let Some(parts) = fragments.parts(*idx) {
347                        let s = resolve_parts(parts, program, line_tables, resolver, fragments);
348                        out.push_str(&s);
349                    }
350                }
351                // B4 (`docs/stdlib-spec.md` §1.6b) — same display-boundary
352                // forgiveness as the `ValueRef` arm of `resolve_part_into`;
353                // the join collapse below already treats an empty slot
354                // fragment correctly.
355                Some(other) => out.push_str(&value_ops::stringify_display(other, program)),
356                None => {}
357            },
358            LinePart::Select {
359                slot,
360                variants,
361                default,
362            } => out.push_str(resolve_select(*slot, variants, default, slots, resolver)),
363            LinePart::Span { children, .. } => {
364                resolve_line_parts_into(
365                    out,
366                    children,
367                    program,
368                    line_tables,
369                    slots,
370                    resolver,
371                    fragments,
372                );
373            }
374        }
375        // Skip empty fragments (null/empty slots) and collapse
376        // whitespace at join points when empty slots produce
377        // adjacent spaces or leading whitespace.
378        if out.len() == start {
379            continue;
380        }
381        let result_empty_or_space = start == base || out[..start].ends_with(' ');
382        if result_empty_or_space && out[start..].starts_with(' ') {
383            let lead = out[start..].len() - out[start..].trim_start().len();
384            out.replace_range(start..start + lead, "");
385        }
386    }
387}
388
389/// Resolve a Select part against its slot value.
390///
391/// Cascade: Exact → Keyword → Cardinal/Ordinal → default.
392fn resolve_select<'a>(
393    slot: u8,
394    variants: &'a [(SelectKey, String)],
395    default: &'a str,
396    slots: &[Value],
397    resolver: Option<&dyn PluralResolver>,
398) -> &'a str {
399    let Some(val) = slots.get(slot as usize) else {
400        return default;
401    };
402
403    #[expect(clippy::cast_possible_truncation)]
404    let n: Option<i64> = match val {
405        Value::Int(i) => Some(i64::from(*i)),
406        Value::Float(f) => Some(*f as i64),
407        _ => None,
408    };
409
410    // Exact match.
411    if let Some(n) = n {
412        #[expect(clippy::cast_possible_truncation)]
413        let n32 = n as i32;
414        for (key, text) in variants {
415            if let SelectKey::Exact(e) = key
416                && *e == n32
417            {
418                return text;
419            }
420        }
421    }
422
423    // Keyword match.
424    if let Value::String(s) = val {
425        for (key, text) in variants {
426            if let SelectKey::Keyword(k) = key
427                && k == s.as_ref()
428            {
429                return text;
430            }
431        }
432    }
433
434    // Plural resolution.
435    if let (Some(n), Some(r)) = (n, resolver) {
436        let cardinal: PluralCategory = r.cardinal(n, None);
437        for (key, text) in variants {
438            if let SelectKey::Cardinal(cat) = key
439                && *cat == cardinal
440            {
441                return text;
442            }
443        }
444        let ordinal: PluralCategory = r.ordinal(n);
445        for (key, text) in variants {
446            if let SelectKey::Ordinal(cat) = key
447                && *cat == ordinal
448            {
449                return text;
450            }
451        }
452    }
453
454    default
455}
456
457/// Where a function's output began: the active target's length at call
458/// time, plus which target it was. The two depths let a later check tell
459/// "the same target, further along" from "a different target" (a string
460/// capture or fragment that began inside the function), where the length
461/// alone would be meaningless (issue #3519).
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub(crate) struct OutputMark {
464    pub(crate) len: usize,
465    pub(crate) capture_depth: usize,
466    pub(crate) fragment_depth: usize,
467}
468
469/// `OutputBuffer` reaches Bevy as part of `bevy-brink`'s `BrinkFlow`
470/// component, and Bevy requires components to be `Send + Sync`. Nothing in
471/// this module names that requirement, and violating it fails nowhere near
472/// here: an interior-mutability field added for a scratch buffer (`RefCell`
473/// and `Cell` are both `!Sync`) surfaced as dozens of
474/// `QueryData`/`IterQueryData` bound errors inside `bevy-brink`, on a CI leg
475/// this crate's own gates never run. Assert it here, where the field would
476/// be added.
477const _: () = {
478    const fn assert_send_sync<T: Send + Sync>() {}
479    assert_send_sync::<OutputBuffer>();
480    assert_send_sync::<OutputPart>();
481};
482
483/// Accumulates output text with glue resolution.
484///
485/// The buffer is split into two storage areas:
486/// - **transcript**: append-only log of all output parts. Never drained.
487///   A read cursor advances on `take_first_line`/`flush_lines`.
488/// - **capture**: transient scratch space for string eval, tag collection,
489///   and function return value capture. Drained by `end_capture`.
490#[derive(Debug, Clone)]
491pub(crate) struct OutputBuffer {
492    /// Reusable scan buffer for [`Self::take_first_line`]'s glue pass.
493    ///
494    /// Not state: it carries nothing between calls and every call refills it
495    /// from scratch. It exists purely so the scan stops allocating: it was
496    /// introduced when `has_completed_line` still ran this scan once per VM
497    /// step and built a fresh `vec![false; unread.len()]` each time —
498    /// measured at 467,587 `calloc` calls against 466,851 steps on
499    /// `crucible-8` (#3565). `has_completed_line` no longer scans at all
500    /// (`completion.rs`); `take_first_line` still does, once per delivered
501    /// line.
502    ///
503    /// A plain field with a `&mut self` receiver, deliberately: `RefCell`
504    /// and `Cell` are both `!Sync`, and one here makes `OutputBuffer` —
505    /// and transitively `bevy-brink`'s `BrinkFlow` component — non-`Sync`,
506    /// which Bevy requires. That failure surfaces far from its cause, as
507    /// dozens of `QueryData`/`IterQueryData` bound errors in `bevy-brink`.
508    line_scan: Vec<bool>,
509    /// Incremental state behind [`Self::has_completed_line`]: the answer the
510    /// glue-and-walk scan would give over `transcript[cursor..]`, kept
511    /// current by [`Self::push_part`] and rebuilt by
512    /// [`Self::rescan_completion`] after a cursor move or a removal. See
513    /// `completion.rs` for why this is exact.
514    completion: LineCompletion,
515    /// Append-only output log. Parts are never removed.
516    pub(crate) transcript: Vec<OutputPart>,
517    /// Read cursor into transcript. Advances on take/flush.
518    pub(crate) cursor: usize,
519    /// Transient capture scratch space.
520    capture: Vec<OutputPart>,
521    /// Nesting depth of active captures. When > 0, pushes route to `capture`.
522    capture_depth: usize,
523    /// Finalized fragments — structural output parts for locale re-rendering.
524    fragments: Fragments,
525    /// Current fragment being captured.
526    fragment_capture: Vec<OutputPart>,
527    /// Fragment capture nesting depth. When > 0, pushes route to `fragment_capture`.
528    fragment_depth: usize,
529    /// Tags accumulated during each nested fragment capture level.
530    fragment_pending_tags: Vec<Vec<String>>,
531    /// Element-attachment state (issue #2108) carried forward across
532    /// separate [`Self::take_first_line`] calls — the streaming, one-line-
533    /// at-a-time API resolves only the slice through each line's own
534    /// completing `Newline`, so a run spanning MULTIPLE lines (ruling item
535    /// 5: "every line in it carries a copy") would otherwise lose the data
536    /// after its first line, once the cursor has advanced past the
537    /// `ElementAttach` parts that live before it. Seeded into
538    /// `resolve_lines_annotated` at the start of each call and updated from
539    /// its trailing state afterward — see `take_first_line`'s own doc.
540    /// [`Self::flush_lines`] needs no equivalent: it resolves the entire
541    /// remaining tail in one call, so the accumulation stays correct
542    /// without carrying anything between separate calls.
543    pending_element: BTreeMap<String, String>,
544}
545
546impl OutputBuffer {
547    pub fn new() -> Self {
548        Self {
549            line_scan: Vec::new(),
550            completion: LineCompletion::default(),
551            transcript: Vec::new(),
552            cursor: 0,
553            capture: Vec::new(),
554            capture_depth: 0,
555            fragments: Fragments::default(),
556            fragment_capture: Vec::new(),
557            fragment_depth: 0,
558            fragment_pending_tags: Vec::new(),
559            pending_element: BTreeMap::new(),
560        }
561    }
562
563    /// Returns the active push target.
564    /// Priority: capture (eagerly resolves) > fragment (structural) > transcript.
565    fn target(&mut self) -> &mut Vec<OutputPart> {
566        if self.capture_depth > 0 {
567            &mut self.capture
568        } else if self.fragment_depth > 0 {
569            &mut self.fragment_capture
570        } else {
571            &mut self.transcript
572        }
573    }
574
575    /// Append `part` to the active target. The one place a part enters the
576    /// transcript, so the line-completion state can follow it there.
577    fn push_part(&mut self, part: OutputPart) {
578        if self.capture_depth == 0 && self.fragment_depth == 0 {
579            self.completion.feed(&part);
580            self.transcript.push(part);
581        } else {
582            self.target().push(part);
583        }
584    }
585
586    /// Where a function's output starts: the active target's length and
587    /// which target it was (by capture/fragment depth), recorded at call
588    /// time on the function's frame — see [`OutputMark`].
589    pub(crate) fn mark(&self) -> OutputMark {
590        OutputMark {
591            len: self.target_len(),
592            capture_depth: self.capture_depth,
593            fragment_depth: self.fragment_depth,
594        }
595    }
596
597    /// Push a newline emitted while `function` (the innermost function
598    /// frame's [`OutputMark`], if the top frame is a function) is active.
599    ///
600    /// Matches the C# runtime's `functionStartInOutputStream` rule
601    /// (`PushToOutputStreamIndividual`): while a function has produced no
602    /// non-whitespace output since it was entered, a newline is dropped
603    /// outright — so a function whose body begins with a conditional block
604    /// (whose branch starts with a newline) does not break the line it was
605    /// called from, even when that line already holds content from an
606    /// earlier call (issue #3519). Once the function has printed, or when a
607    /// string capture / fragment began inside it (C#'s `BeginString`
608    /// exception — the mark no longer names the active target), the
609    /// ordinary [`Self::push_newline`] rules apply.
610    pub(crate) fn push_newline_in_function(&mut self, function: Option<OutputMark>) {
611        if let Some(mark) = function
612            && mark.capture_depth == self.capture_depth
613            && mark.fragment_depth == self.fragment_depth
614            && self
615                .target_ref()
616                .get(mark.len..)
617                .is_some_and(|since_call| !since_call.iter().any(OutputPart::is_content))
618        {
619            return;
620        }
621        self.push_newline();
622    }
623
624    /// The active push target, read-only — same priority as [`Self::target`].
625    fn target_ref(&self) -> &Vec<OutputPart> {
626        if self.capture_depth > 0 {
627            &self.capture
628        } else if self.fragment_depth > 0 {
629            &self.fragment_capture
630        } else {
631            &self.transcript
632        }
633    }
634
635    /// Length of the active push target. Used to record function output
636    /// start points for trailing whitespace trim on return.
637    pub(crate) fn target_len(&self) -> usize {
638        if self.capture_depth > 0 {
639            self.capture.len()
640        } else if self.fragment_depth > 0 {
641            self.fragment_capture.len()
642        } else {
643            self.transcript.len()
644        }
645    }
646
647    /// Trim trailing whitespace from the active output target, walking
648    /// backward to `start`. Matches the C# runtime's
649    /// `TrimWhitespaceFromFunctionEnd`: on function return, remove
650    /// trailing `Newline`, `Spring`, and whitespace-only text so that
651    /// function output doesn't inject unwanted line breaks.
652    ///
653    /// `Glue` is transparent to the walk, as it is to the C# loop (which
654    /// `continue`s past every non-text object): the glue stays, and the
655    /// whitespace beneath it goes — so `{x} <>` at the end of a function
656    /// leaves `x` glued to whatever follows, not `x ` (issue #3522).
657    ///
658    /// **The walk stops at the read cursor.** A function whose body spans a
659    /// yield point — it printed a line, the consumer took it, and only then
660    /// did the function return — has a `start` recorded before parts that
661    /// have since been delivered, and trimming those is both meaningless and
662    /// destructive. C# has no such case to handle because its output stream
663    /// really is emptied at each yield (`ResetOutput`), leaving nothing
664    /// behind the equivalent point; brink keeps the whole transcript with a
665    /// cursor over it, so the cursor is where C#'s reset happened and is the
666    /// floor the walk owes (issue #3539).
667    ///
668    /// Without the floor the transcript can end up shorter than the cursor,
669    /// and every reader of `transcript[cursor..]` panics on the next step —
670    /// which is how this surfaced. Silently worse: a locale hot-swap
671    /// re-renders from `reset_cursor`, so parts trimmed from behind the
672    /// cursor would vanish from a re-render of output the consumer had
673    /// already been shown.
674    pub(crate) fn trim_function_end(&mut self, start: usize) {
675        // The cursor indexes the transcript alone, so it is a floor only
676        // when the transcript is the active target — inside a string
677        // capture or a fragment, `start` names a position in *that* buffer
678        // and the cursor says nothing about it.
679        let floor = if self.capture_depth == 0 && self.fragment_depth == 0 {
680            start.max(self.cursor)
681        } else {
682            start
683        };
684        let on_transcript = self.capture_depth == 0 && self.fragment_depth == 0;
685        let target = self.target();
686        let mut removed = false;
687        let mut i = target.len();
688        while i > floor {
689            i -= 1;
690            let trimmable = match &target[i] {
691                // Glue is transparent to the trim (issue #3522), neither
692                // removed nor a stopping point.
693                OutputPart::Glue => continue,
694                OutputPart::Newline | OutputPart::Spring => true,
695                OutputPart::Text(s) => s.trim().is_empty(),
696                OutputPart::LineRef { flags, .. } => {
697                    flags.contains(brink_format::LineFlags::ALL_WS)
698                }
699                // Issue #3536: a value that renders as whitespace — an
700                // empty list, `""`, a `none` — is trimmed exactly like
701                // whitespace text. ink stringifies values into the output
702                // stream as they are pushed, so by the time its
703                // `TrimWhitespaceFromFunctionEnd` runs an empty
704                // interpolation is an inline-whitespace `StringValue`
705                // there; brink resolves values later (the transcript holds
706                // an unresolved `ValueRef`), so the same judgement is made
707                // here from the value itself. A value that renders visibly
708                // still stops the trim.
709                part @ OutputPart::ValueRef(_) => !part.is_visible(),
710                _ => false,
711            };
712            if !trimmable {
713                break;
714            }
715            target.remove(i);
716            removed = true;
717        }
718        if removed && on_transcript {
719            self.rescan_completion();
720        }
721    }
722
723    /// No longer called by the VM — candidate for removal.
724    #[cfg(test)]
725    pub fn push_text(&mut self, text: &str) {
726        if text.is_empty() {
727            return;
728        }
729        // Suppress whitespace-only text when there's no content yet,
730        // matching the C# ink runtime's output stream filtering.
731        // This handles leading spaces after choice selection (`"^ "`).
732        if !self.has_content() && text.trim().is_empty() {
733            return;
734        }
735        // Collapse adjacent whitespace at text boundaries: if the
736        // previous text part ends with whitespace and this text starts
737        // with whitespace, trim the leading whitespace from this text.
738        let text = if text.starts_with(char::is_whitespace) && self.ends_in_whitespace() {
739            text.trim_start()
740        } else {
741            text
742        };
743        if !text.is_empty() {
744            self.push_part(OutputPart::Text(text.to_owned()));
745        }
746    }
747
748    pub fn push_newline(&mut self) {
749        // Suppress leading newlines (no content yet) and duplicate newlines,
750        // matching the C# ink runtime's output stream filtering.
751        //
752        // Inside a capture, use scope-local has_content().  Outside, check
753        // the unread transcript for content **or Spring** — Spring is brink's
754        // equivalent of the C# `"^ "` (space) that inklecate emits in choice
755        // targets.  In C#, that space is a StringValue which makes
756        // `outputStreamContainsContent` true, allowing the subsequent newline
757        // through.  Without counting Spring, post-choice newlines are lost.
758        let has_content = if self.capture_depth > 0 || self.fragment_depth > 0 {
759            self.has_content()
760        } else {
761            self.unread_has_content_or_spring()
762        };
763        if !has_content || self.ends_in_newline() {
764            return;
765        }
766        self.push_part(OutputPart::Newline);
767    }
768
769    /// Returns true if the active target contains any text content.
770    /// When inside a capture, scans the capture vec (stopping at checkpoint).
771    /// When inside a fragment (and no capture is active — same priority
772    /// `target()` uses), scans `fragment_capture` the identical way, stopping
773    /// at *its own* checkpoint (issue #1839: a fragment capturing more than
774    /// one recognized line needs this to see the lines it has already
775    /// captured at THIS nesting level, not the outer transcript, which a
776    /// multi-statement block capture is the first producer to ever exercise
777    /// — every earlier fragment use captured at most one call's worth of
778    /// output). When neither is active, scans the transcript from cursor
779    /// position.
780    fn has_content(&self) -> bool {
781        if self.capture_depth > 0 {
782            self.capture
783                .iter()
784                .rev()
785                .take_while(|p| !matches!(p, OutputPart::Checkpoint))
786                .any(OutputPart::is_content)
787        } else if self.fragment_depth > 0 {
788            self.fragment_capture
789                .iter()
790                .rev()
791                .take_while(|p| !matches!(p, OutputPart::Checkpoint))
792                .any(OutputPart::is_content)
793        } else {
794            self.transcript[self.cursor..]
795                .iter()
796                .rev()
797                .any(OutputPart::is_content)
798        }
799    }
800
801    /// Returns true if the unread transcript contains content or a Spring.
802    ///
803    /// This mirrors the C# runtime's `outputStreamContainsContent` check,
804    /// which returns true for ANY `StringValue` in the output stream.  In C#,
805    /// the choice target's `"^ "` (a space) is a `StringValue` — its brink
806    /// equivalent is `Spring`.  After `ResetOutput()` clears the stream at the
807    /// start of each `Continue()`, the choice target's space is the first thing
808    /// pushed, making `outputStreamContainsContent` true.  In brink, the
809    /// cursor advance at yield points has the same effect as `ResetOutput()`,
810    /// so checking unread parts mirrors the per-`Continue()` scope.
811    fn unread_has_content_or_spring(&self) -> bool {
812        self.transcript[self.cursor..]
813            .iter()
814            .any(|p| p.is_content() || matches!(p, OutputPart::Spring))
815    }
816
817    /// Returns true if the last part in the active target is a newline.
818    /// Same three-way priority as [`Self::has_content`] (issue #1839).
819    fn ends_in_newline(&self) -> bool {
820        let target = if self.capture_depth > 0 {
821            &self.capture
822        } else if self.fragment_depth > 0 {
823            &self.fragment_capture
824        } else {
825            &self.transcript
826        };
827        matches!(target.last(), Some(OutputPart::Newline))
828    }
829
830    /// Returns true if the last part is text ending with whitespace.
831    /// Only checks the immediately preceding part — intervening Glue or
832    /// Newline parts mean the glue system handles the join instead.
833    ///
834    /// `LineRef` is not inspected: `LineFlags` no longer carries an
835    /// edge-whitespace bit (`STARTS_WITH_WS`/`ENDS_WITH_WS` were removed —
836    /// they had no production consumer, and the C# reference runtime never
837    /// does sub-token leading/trailing whitespace detection either, so there
838    /// was no conformance gap to preserve). A resolved `LineRef` is treated
839    /// as not ending in whitespace, same as before this helper had any
840    /// `LineRef` case.
841    #[cfg(test)]
842    fn ends_in_whitespace(&self) -> bool {
843        let target = if self.capture_depth > 0 {
844            &self.capture
845        } else if self.fragment_depth > 0 {
846            &self.fragment_capture
847        } else {
848            &self.transcript
849        };
850        matches!(target.last(), Some(OutputPart::Text(s)) if s.ends_with(char::is_whitespace))
851    }
852
853    pub fn push_glue(&mut self) {
854        self.push_part(OutputPart::Glue);
855    }
856
857    /// Push a word break. Deduplicated: no consecutive Springs.
858    pub fn push_spring(&mut self) {
859        if !matches!(self.target_ref().last(), Some(OutputPart::Spring)) {
860            self.push_part(OutputPart::Spring);
861        }
862    }
863
864    /// Push a deferred line reference. Resolved at read time.
865    /// Applies the same filtering as `push_text` using precomputed flags.
866    pub fn push_line_ref(
867        &mut self,
868        container_idx: u32,
869        line_idx: u16,
870        slots: Vec<Value>,
871        flags: brink_format::LineFlags,
872    ) {
873        // Suppress whitespace-only/empty content when there's no content yet.
874        if !self.has_content()
875            && (flags.contains(brink_format::LineFlags::ALL_WS)
876                || flags.contains(brink_format::LineFlags::EMPTY))
877        {
878            return;
879        }
880        self.push_part(OutputPart::LineRef {
881            container_idx,
882            line_idx,
883            slots,
884            flags,
885        });
886    }
887
888    /// Push a deferred value. Stringified at read time.
889    /// Null values are dropped (they stringify to empty string).
890    pub fn push_value_ref(&mut self, value: Value) {
891        if matches!(value, Value::Null) {
892            return;
893        }
894        // Suppress whitespace-only string values when there's no content yet.
895        if !self.has_content()
896            && let Value::String(ref s) = value
897            && s.trim().is_empty()
898        {
899            return;
900        }
901        self.push_part(OutputPart::ValueRef(value));
902    }
903
904    /// Push a tag associated with the current output line.
905    pub fn push_tag(&mut self, tag: String) {
906        self.push_part(OutputPart::Tag(tag));
907    }
908
909    /// Merge one field of an `attach = StructName` handler's return value
910    /// into the currently open run (issue #2108, `Opcode::AttachElement`'s
911    /// handler). See [`OutputPart::ElementAttach`]'s doc for why this is a
912    /// transcript entry rather than a `Flow`-level mutation.
913    pub(crate) fn push_element_attach(&mut self, key: String, value: String) {
914        self.push_part(OutputPart::ElementAttach(key, value));
915    }
916
917    /// Close the run the most recent [`Self::push_element_attach`] calls
918    /// opened (`Opcode::EndElementRun`'s handler). See
919    /// [`OutputPart::ElementAttachEnd`]'s doc.
920    pub(crate) fn push_element_attach_end(&mut self) {
921        self.push_part(OutputPart::ElementAttachEnd);
922    }
923
924    /// Returns true if a capture is currently active.
925    /// Whether a string-eval/tag/function-return capture is active — pushes
926    /// currently route to transient scratch, not visible output (NS-A2:
927    /// the `effect-trace` emit recorder's visibility guard; unused in
928    /// ordinary builds, hence the allow).
929    #[cfg_attr(not(feature = "effect-trace"), expect(dead_code))]
930    pub fn in_capture(&self) -> bool {
931        self.capture_depth > 0
932    }
933
934    pub fn has_checkpoint(&self) -> bool {
935        self.capture_depth > 0
936    }
937
938    /// Begin a capture. Pushes a checkpoint to the capture scratch space.
939    /// While a capture is active, all pushes route to the capture vec.
940    pub fn begin_capture(&mut self) {
941        self.capture_depth += 1;
942        self.capture.push(OutputPart::Checkpoint);
943    }
944
945    /// End the most recent capture: drain from the last checkpoint in the
946    /// capture vec, resolve glue, and return the result as a string.
947    ///
948    /// Returns `None` if there is no checkpoint.
949    pub fn end_capture(
950        &mut self,
951        program: &Program,
952        line_tables: &[Vec<LineEntry>],
953        resolver: Option<&dyn PluralResolver>,
954    ) -> Option<String> {
955        let cp_idx = self
956            .capture
957            .iter()
958            .rposition(|p| matches!(p, OutputPart::Checkpoint))?;
959
960        let captured: Vec<OutputPart> = self.capture.drain(cp_idx..).collect();
961        // Skip the checkpoint itself (first element).
962        let captured = &captured[1..];
963
964        self.capture_depth = self.capture_depth.saturating_sub(1);
965
966        Some(resolve_parts(
967            captured,
968            program,
969            line_tables,
970            resolver,
971            &self.fragments,
972        ))
973    }
974}
975
976/// First pass of glue resolution: mark newlines and glue parts for removal.
977///
978/// For each `Glue` part, find the nearest preceding `Newline` (skipping
979/// whitespace-only text, tags, checkpoints, and already-removed parts)
980/// and mark both the newline and the glue for removal.
981fn mark_glue_removals(parts: &[OutputPart], remove: &mut [bool]) {
982    for (i, part) in parts.iter().enumerate() {
983        if matches!(part, OutputPart::Glue) {
984            for j in (0..i).rev() {
985                if remove[j] {
986                    continue;
987                }
988                match &parts[j] {
989                    OutputPart::Newline => {
990                        remove[j] = true;
991                        break;
992                    }
993                    OutputPart::Glue
994                    | OutputPart::Checkpoint
995                    | OutputPart::Tag(_)
996                    | OutputPart::Spring
997                    | OutputPart::ElementAttach(..)
998                    | OutputPart::ElementAttachEnd
999                    // B4 (`docs/stdlib-spec.md` §1.6b): a final-`None`
1000                    // value renders empty at the display boundary — same
1001                    // pass-through treatment as whitespace-only text below,
1002                    // consistent with `OutputPart::is_content`.
1003                    | OutputPart::ValueRef(Value::OptionVal(None)) => {}
1004                    OutputPart::Text(s) if s.trim().is_empty() => {}
1005                    // A whitespace-only or empty line-table line is
1006                    // whitespace-only text by another name (issue #3507:
1007                    // a lifted arm that rendered to `" "` before glue) —
1008                    // it is not content and does not block the scan,
1009                    // exactly as `is_content` already classifies it.
1010                    OutputPart::LineRef { flags, .. }
1011                        if flags.contains(brink_format::LineFlags::ALL_WS)
1012                            || flags.contains(brink_format::LineFlags::EMPTY) => {}
1013                    // Content (Text, LineRef, ValueRef) blocks glue scan.
1014                    OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
1015                        break;
1016                    }
1017                }
1018            }
1019            remove[i] = true;
1020        }
1021    }
1022}
1023
1024/// Resolve glue in a slice of output parts and return the flattened string.
1025///
1026/// Mirrors `resolve_lines_annotated`'s per-line suppression (issue #2091,
1027/// extended to this path by issue #2147 — the string-capture path #2091's
1028/// PR #2140 did not touch): if a line within the captured text resolves
1029/// fully empty and at least one of its parts interpolated a `content`-typed
1030/// value (`Value::FragmentRef`) that itself rendered empty, the line is
1031/// dropped entirely — not left behind as a blank line — same as the
1032/// streaming/batch `resolve_lines` path.
1033///
1034/// `resolve_lines_annotated` does **not** call this function directly — the
1035/// two hold independent copies of the same suppression logic, applied at
1036/// different granularities. `resolve_parts`'s real callers are:
1037///
1038/// - [`OutputBuffer::end_capture`] — `Opcode::EndStringEval`'s resolution
1039///   path (e.g. an unrecognized choice display, or any
1040///   `~ temp x = "..."` string-eval capture);
1041/// - [`OutputBuffer::resolve_fragment`] (`output/fragment.rs`) — the
1042///   resolver `ChoiceDisplay::Fragment` reads through (`story/mod.rs`,
1043///   `story/flow_instance.rs`), so a captured choice's display text is
1044///   affected too (`brink-cli`'s `tui/app.rs` reads it from there);
1045/// - [`resolve_part`]'s `ValueRef(Value::FragmentRef)` arm and
1046///   [`resolve_line_parts`]'s `LinePart::Slot` `FragmentRef` arm — both
1047///   recurse into `resolve_parts` to resolve a fragment's own *interior*,
1048///   and both are themselves reachable from `resolve_lines_annotated`'s
1049///   top-level resolution whenever a rendered line references a fragment.
1050///   So this suppression also reaches inside any nested, multi-line
1051///   fragment rendered on the streaming/batch path — a blank line
1052///   contributed purely by an inner, rendered-empty fragment now vanishes
1053///   from the *interior* of an outer fragment's captured text too, not
1054///   only at a transcript line's own top level.
1055///
1056/// No `current_tags`-style tag exception is needed here (unlike
1057/// `resolve_lines_annotated`): a `Tag` already sets `after_glue`, which
1058/// unconditionally skips the newline right after it (pre-existing behavior,
1059/// untouched by this fix) — so a tag-then-newline sequence never reaches
1060/// this suppression check in the first place, and tags carry no characters
1061/// into a captured string's text regardless.
1062fn resolve_parts(
1063    parts: &[OutputPart],
1064    program: &Program,
1065    line_tables: &[Vec<LineEntry>],
1066    resolver: Option<&dyn PluralResolver>,
1067    fragments: &Fragments,
1068) -> String {
1069    // First pass: mark newlines that should be removed by glue.
1070    let mut remove = vec![false; parts.len()];
1071    mark_glue_removals(parts, &mut remove);
1072
1073    let mut out = String::new();
1074    let mut after_glue = false;
1075    // issue #2147: track the start of the current (in-progress) line within
1076    // `out`, and whether it saw a `content`/Fragment interpolation, so a
1077    // line that resolves fully empty purely from a rendered-empty fragment
1078    // can be dropped rather than left as a stray blank line.
1079    let mut line_start = 0usize;
1080    let mut saw_fragment_ref = false;
1081    // Issue #3507: where the current line began in `out`, counting a
1082    // glue-removed `Newline` too (unlike `line_start`, which only moves on
1083    // a kept one). ink's glue trims the trailing newline AND every
1084    // whitespace-only string after it (`TrimNewlinesFromOutputStream`), so
1085    // `a` / `{false:x} <>` / `b` prints `ab`: the spring's space after the
1086    // empty construct dies with the newline. When content DID land on the
1087    // line (`{0} <>`), the newline is not trailing and the space survives
1088    // (`0 world`).
1089    let mut since_newline = 0usize;
1090
1091    for (i, part) in parts.iter().enumerate() {
1092        if remove[i] {
1093            match part {
1094                OutputPart::Glue => {
1095                    after_glue = true;
1096                    if out[since_newline..].trim().is_empty() {
1097                        out.truncate(since_newline);
1098                    }
1099                }
1100                OutputPart::Newline => since_newline = out.len(),
1101                _ => {}
1102            }
1103            continue;
1104        }
1105        match part {
1106            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
1107                if part_involves_fragment_ref(part) {
1108                    saw_fragment_ref = true;
1109                }
1110                let start = out.len();
1111                resolve_part_into(part, &mut out, program, line_tables, resolver, fragments);
1112                // Collapse adjacent whitespace at part boundaries.
1113                if collapse_join(&mut out, start) {
1114                    after_glue = false;
1115                }
1116            }
1117            OutputPart::Spring => {
1118                // Emit " " unless output is empty, ends in space, or ends in newline.
1119                if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
1120                    out.push(' ');
1121                }
1122            }
1123            OutputPart::Newline => {
1124                if !after_glue {
1125                    let trimmed_len = out.trim_end_matches([' ', '\t']).len();
1126                    out.truncate(trimmed_len);
1127                    if saw_fragment_ref && out[line_start..].trim().is_empty() {
1128                        // Suppress: drop the whole (whitespace-only) line
1129                        // and its trailing newline, not just its text.
1130                        out.truncate(line_start);
1131                    } else {
1132                        out.push('\n');
1133                        line_start = out.len();
1134                    }
1135                    saw_fragment_ref = false;
1136                }
1137                since_newline = out.len();
1138            }
1139            OutputPart::Glue
1140            | OutputPart::Checkpoint
1141            | OutputPart::Tag(_)
1142            | OutputPart::ElementAttach(..)
1143            | OutputPart::ElementAttachEnd => {
1144                after_glue = true;
1145            }
1146        }
1147    }
1148
1149    // issue #2147 (trailing-entry parity with `resolve_lines_annotated`'s
1150    // own `EXCEPTION (issue #2091)` handling of its final, unterminated
1151    // entry): a captured string need not end on a `Newline` part. If the
1152    // text since the last committed line resolves empty and interpolated a
1153    // Fragment, drop it AND the newline that introduced it — mirroring how
1154    // `resolve_lines` drops that trailing entry from its `Vec` whole (no
1155    // join separator left behind for it either). Without this, parts like
1156    // `[Text("a"), Newline, ValueRef(FragmentRef(<empty>))]` resolved to
1157    // `"a\n"` here while `resolve_lines` (joining its per-line `Vec`, which
1158    // dropped the suppressed trailing entry) produced just `"a"`.
1159    if saw_fragment_ref && line_start > 0 && out[line_start..].trim().is_empty() {
1160        out.truncate(line_start - 1);
1161    }
1162
1163    out
1164}
1165
1166/// Returns true if `part` interpolates a `content`-typed value
1167/// (`Value::FragmentRef`) — either directly (`ValueRef`) or through a
1168/// template `Slot` (`LineRef`).
1169///
1170/// Two distinct mechanisms produce a `FragmentRef` in this position, and
1171/// this check does not — and structurally cannot — tell them apart: issue
1172/// #1839's `block`-capture receiver, AND the ordinary display-position
1173/// call-composition pattern `brink-codegen-inkb::content::emit_slot_expr`
1174/// emits for *every* template slot whose expr is a function call
1175/// (`lir::Expr::is_function_call()`, both dialects) — e.g. a line whose
1176/// only content is `{ f() }`. Both are suppressed identically by the
1177/// caller.
1178///
1179/// Purely structural: it does not need to look inside the referenced
1180/// fragment to decide suppression. If a line's fully-resolved text comes
1181/// out empty *and* one of its parts involved a fragment reference, that is
1182/// sufficient evidence the fragment itself **rendered** empty. It does
1183/// *not* follow that the fragment "captured nothing" — a fragment that
1184/// captured a line which itself renders empty (e.g. an interpolated empty
1185/// variable), or a call-composition fragment whose function simply
1186/// returned `""`, both reach this same state. "Rendered empty" is the
1187/// weaker, sufficient invariant suppression actually relies on.
1188fn part_involves_fragment_ref(part: &OutputPart) -> bool {
1189    match part {
1190        OutputPart::LineRef { slots, .. } => {
1191            slots.iter().any(|v| matches!(v, Value::FragmentRef(_)))
1192        }
1193        OutputPart::ValueRef(Value::FragmentRef(_)) => true,
1194        _ => false,
1195    }
1196}
1197
1198/// Resolve glue and split into per-line output with associated tags and
1199/// element-attachment data.
1200///
1201/// A resolved line: text, tags, element-attachment data (issue #2108,
1202/// [`OutputPart::ElementAttach`]'s own doc), and the line's source
1203/// location (W7/#3300 transcript provenance — the FIRST `LineRef` part's
1204/// line-table `source_location`; `None` when the line has no `LineRef`,
1205/// e.g. pure interpolation, or its entry carries no location).
1206pub(crate) type ResolvedLine = (
1207    String,
1208    Vec<String>,
1209    BTreeMap<String, String>,
1210    Option<brink_format::SourceLocation>,
1211);
1212
1213/// [`ResolvedLine`] plus the issue #2091 suppression flag —
1214/// `resolve_lines_annotated`'s own unfiltered form.
1215pub(crate) type AnnotatedResolvedLine = (
1216    String,
1217    Vec<String>,
1218    bool,
1219    BTreeMap<String, String>,
1220    Option<brink_format::SourceLocation>,
1221);
1222
1223/// Each returned element is `(line_text, line_tags, line_element_data)`.
1224/// Tags reset every line; element-attachment data (issue #2108) persists
1225/// across lines until an `ElementAttachEnd` closes the run — see
1226/// [`OutputPart::ElementAttach`]'s own doc. Lines that
1227/// `resolve_lines_annotated` marks suppressed (issue #2091 — an empty
1228/// `content`/Fragment capture) are dropped entirely; nothing else changes.
1229pub(crate) fn resolve_lines(
1230    parts: &[OutputPart],
1231    program: &Program,
1232    line_tables: &[Vec<LineEntry>],
1233    resolver: Option<&dyn PluralResolver>,
1234    fragments: &Fragments,
1235) -> Vec<ResolvedLine> {
1236    if parts.is_empty() {
1237        return Vec::new();
1238    }
1239    let mut remove = vec![false; parts.len()];
1240    mark_glue_removals(parts, &mut remove);
1241    resolve_lines_marked(
1242        parts,
1243        &remove,
1244        BTreeMap::new(),
1245        program,
1246        line_tables,
1247        resolver,
1248        fragments,
1249    )
1250    .0
1251}
1252
1253/// The batch resolver as its consumers want it: suppressed entries already
1254/// dropped, plus the element-attachment state at the end of the slice for
1255/// the caller to carry forward. One pass, one `Vec` — the annotated
1256/// intermediate that `resolve_lines_annotated_marked` materializes only
1257/// to be filtered again is not built.
1258pub(crate) fn resolve_lines_marked(
1259    parts: &[OutputPart],
1260    remove: &[bool],
1261    seed_element: BTreeMap<String, String>,
1262    program: &Program,
1263    line_tables: &[Vec<LineEntry>],
1264    resolver: Option<&dyn PluralResolver>,
1265    fragments: &Fragments,
1266) -> (Vec<ResolvedLine>, BTreeMap<String, String>) {
1267    if parts.is_empty() {
1268        return (Vec::new(), seed_element);
1269    }
1270    let mut lines: Vec<ResolvedLine> = Vec::new();
1271    let (text, tags, suppressed, element, source) = drive_lines(
1272        parts,
1273        remove,
1274        seed_element,
1275        program,
1276        line_tables,
1277        resolver,
1278        fragments,
1279        |(text, tags, suppressed, element, source)| {
1280            if !suppressed {
1281                lines.push((text, tags, element, source));
1282            }
1283        },
1284    );
1285    if suppressed {
1286        (lines, element)
1287    } else {
1288        let carried = element.clone();
1289        lines.push((text, tags, element, source));
1290        (lines, carried)
1291    }
1292}
1293
1294fn widen_source(
1295    current: &mut Option<brink_format::SourceLocation>,
1296    entry: Option<&brink_format::SourceLocation>,
1297) {
1298    match (current, entry) {
1299        (current @ None, Some(src)) => *current = Some(src.clone()),
1300        (Some(cur), Some(src)) if cur.file == src.file => {
1301            cur.range_start = cur.range_start.min(src.range_start);
1302            cur.range_end = cur.range_end.max(src.range_end);
1303        }
1304        _ => {}
1305    }
1306}
1307
1308/// The batch walk over precomputed glue marks (`remove[i]` is whether
1309/// `parts[i]` is a glue-removed part, as [`mark_glue_removals`] fills them
1310/// in for exactly this slice), keeping every entry with its `suppressed`
1311/// flag. The result always carries one final entry for the text after the
1312/// last `Newline` (possibly empty) — its element field is the attachment
1313/// state the caller carries forward.
1314///
1315/// Production consumers use [`resolve_lines_marked`] (filtered) or
1316/// [`resolve_first_line_annotated`] (streaming); this is the reference
1317/// shape the streaming resolver is tested against.
1318#[cfg(test)]
1319pub(crate) fn resolve_lines_annotated_marked(
1320    parts: &[OutputPart],
1321    remove: &[bool],
1322    seed_element: BTreeMap<String, String>,
1323    program: &Program,
1324    line_tables: &[Vec<LineEntry>],
1325    resolver: Option<&dyn PluralResolver>,
1326    fragments: &Fragments,
1327) -> Vec<AnnotatedResolvedLine> {
1328    if parts.is_empty() {
1329        return Vec::new();
1330    }
1331    let mut lines: Vec<AnnotatedResolvedLine> = Vec::new();
1332    let trailing = drive_lines(
1333        parts,
1334        remove,
1335        seed_element,
1336        program,
1337        line_tables,
1338        resolver,
1339        fragments,
1340        |line| lines.push(line),
1341    );
1342    lines.push(trailing);
1343    lines
1344}
1345
1346/// The streaming shape of the batch walk (`resolve_lines_annotated_marked`): resolve a
1347/// slice that [`OutputBuffer::take_first_line`] has cut to end exactly on
1348/// the first completed line's `Newline`, returning that line and the
1349/// element-attachment state to carry into the next call — without
1350/// materialising a `Vec` for what is, by construction, one line plus an
1351/// empty trailing entry.
1352///
1353/// Faithful to the batch path's contract even off that construction: the
1354/// returned line is the first one the walk produces (the trailing entry if
1355/// it produces none — a slice that is all glue-removed or after-glue), and
1356/// the carried state is the element field of whatever entry follows it.
1357pub(crate) fn resolve_first_line_annotated(
1358    parts: &[OutputPart],
1359    remove: &[bool],
1360    seed_element: BTreeMap<String, String>,
1361    program: &Program,
1362    line_tables: &[Vec<LineEntry>],
1363    resolver: Option<&dyn PluralResolver>,
1364    fragments: &Fragments,
1365) -> (AnnotatedResolvedLine, BTreeMap<String, String>) {
1366    let mut first: Option<AnnotatedResolvedLine> = None;
1367    let mut next_element: Option<BTreeMap<String, String>> = None;
1368    let trailing = drive_lines(
1369        parts,
1370        remove,
1371        seed_element,
1372        program,
1373        line_tables,
1374        resolver,
1375        fragments,
1376        |line| {
1377            if first.is_none() {
1378                first = Some(line);
1379            } else if next_element.is_none() {
1380                next_element = Some(line.3);
1381            }
1382        },
1383    );
1384    match first {
1385        Some(line) => (line, next_element.unwrap_or(trailing.3)),
1386        None => (trailing, BTreeMap::new()),
1387    }
1388}
1389
1390/// Trim leading and trailing whitespace without reallocating: the tail is
1391/// truncated and the head shifted down in place. The buffer keeps its
1392/// capacity, which is what lets `take_first_line`'s terminating `'\n'`
1393/// land without a `realloc` on the common line.
1394fn trim_in_place(s: &mut String) {
1395    trim_in_place_matches(s, char::is_whitespace);
1396}
1397
1398/// [`trim_in_place`] for an arbitrary character predicate — the in-place
1399/// form of `s.trim_matches(pred).to_string()`.
1400pub(crate) fn trim_in_place_matches(s: &mut String, pred: impl Fn(char) -> bool + Copy) {
1401    let end = s.trim_end_matches(pred).len();
1402    s.truncate(end);
1403    let lead = s.len() - s.trim_start_matches(pred).len();
1404    if lead > 0 {
1405        s.replace_range(..lead, "");
1406    }
1407}
1408
1409/// One linear pass over `parts`, emitting each completed line through
1410/// `emit` and returning the trailing (unterminated) entry. Shared by the
1411/// batch and streaming resolvers above so the two cannot drift.
1412#[expect(
1413    clippy::too_many_arguments,
1414    reason = "the resolver context plus the sink"
1415)]
1416fn drive_lines(
1417    parts: &[OutputPart],
1418    remove: &[bool],
1419    seed_element: BTreeMap<String, String>,
1420    program: &Program,
1421    line_tables: &[Vec<LineEntry>],
1422    resolver: Option<&dyn PluralResolver>,
1423    fragments: &Fragments,
1424    mut emit: impl FnMut(AnnotatedResolvedLine),
1425) -> AnnotatedResolvedLine {
1426    debug_assert_eq!(remove.len(), parts.len(), "one glue mark per part");
1427    let mut current_text = String::new();
1428    let mut current_tags: Vec<String> = Vec::new();
1429    // Issue #2108: unlike `current_tags` (reset every line), this
1430    // ACCUMULATES across lines — cleared only by `ElementAttachEnd` — so
1431    // every line materialized while a run is open gets a copy (ruling item
1432    // 5). Cloned, never moved, into each pushed line entry below. Seeded
1433    // from the caller's already-accumulated state (see this function's own
1434    // `seed_element` doc) rather than always starting empty.
1435    let mut current_element: BTreeMap<String, String> = seed_element;
1436    // The line's provenance (W7/#3300): the span of its `LineRef`s'
1437    // line-table `source_location`s — from the first ref's start to the
1438    // furthest end among refs in that same file (a glue-joined line, or a
1439    // prose-dialect cue + aside + dialogue, spans several source lines and
1440    // the host highlights them all; feedback 2026-09-02). A ref from
1441    // another file never widens it. Reset per line.
1442    let mut current_source: Option<brink_format::SourceLocation> = None;
1443    let mut saw_fragment_ref = false;
1444    let mut after_glue = false;
1445    // Issue #3507 — see `resolve_parts`'s `since_newline`: the
1446    // point in `current_text` where the current source line began, counting
1447    // a glue-removed newline, so glue can drop whitespace-only text that
1448    // followed that newline the way ink's `TrimNewlinesFromOutputStream`
1449    // does.
1450    let mut since_newline = 0usize;
1451
1452    for (i, part) in parts.iter().enumerate() {
1453        if remove[i] {
1454            match part {
1455                OutputPart::Glue => {
1456                    after_glue = true;
1457                    if current_text[since_newline..].trim().is_empty() {
1458                        current_text.truncate(since_newline);
1459                    }
1460                }
1461                OutputPart::Newline => since_newline = current_text.len(),
1462                _ => {}
1463            }
1464            continue;
1465        }
1466        match part {
1467            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
1468                if let OutputPart::LineRef {
1469                    container_idx,
1470                    line_idx,
1471                    ..
1472                } = part
1473                {
1474                    // Same table selection as `resolve_line_ref`: a
1475                    // `LineRef`'s `container_idx` keys the SCOPE table via
1476                    // `scope_table_idx`, never `line_tables` directly —
1477                    // indexing raw silently reads another scope's line
1478                    // (found live: every provenance chip pointed at the
1479                    // wrong place while the TEXT — resolved through the
1480                    // correct road — looked fine).
1481                    let scope_idx = program.scope_table_idx(*container_idx) as usize;
1482                    let entry_source = line_tables
1483                        .get(scope_idx)
1484                        .and_then(|t| t.get(*line_idx as usize))
1485                        .and_then(|entry| entry.source_location.as_ref());
1486                    widen_source(&mut current_source, entry_source);
1487                }
1488                if part_involves_fragment_ref(part) {
1489                    saw_fragment_ref = true;
1490                }
1491                let start = current_text.len();
1492                resolve_part_into(
1493                    part,
1494                    &mut current_text,
1495                    program,
1496                    line_tables,
1497                    resolver,
1498                    fragments,
1499                );
1500                // Collapse adjacent whitespace at part boundaries.
1501                if collapse_join(&mut current_text, start) {
1502                    after_glue = false;
1503                }
1504            }
1505            OutputPart::Spring => {
1506                if !current_text.is_empty()
1507                    && !current_text.ends_with(' ')
1508                    && !current_text.ends_with('\n')
1509                {
1510                    current_text.push(' ');
1511                }
1512            }
1513            OutputPart::Newline => {
1514                if !after_glue {
1515                    trim_in_place(&mut current_text);
1516                    let suppressed =
1517                        current_text.is_empty() && current_tags.is_empty() && saw_fragment_ref;
1518                    emit((
1519                        mem::take(&mut current_text),
1520                        mem::take(&mut current_tags),
1521                        suppressed,
1522                        current_element.clone(),
1523                        current_source.take(),
1524                    ));
1525                    saw_fragment_ref = false;
1526                }
1527                since_newline = current_text.len();
1528            }
1529            OutputPart::Tag(tag) => {
1530                current_tags.push(tag.clone());
1531            }
1532            OutputPart::ElementAttach(key, value) => {
1533                current_element.insert(key.clone(), value.clone());
1534            }
1535            OutputPart::ElementAttachEnd => {
1536                current_element.clear();
1537            }
1538            OutputPart::Glue | OutputPart::Checkpoint => {
1539                after_glue = true;
1540            }
1541        }
1542    }
1543
1544    // Push the final line — even if empty — so that a trailing Newline
1545    // part produces a trailing `\n` when the lines are joined by
1546    // `resolve_lines`'s callers (e.g. `flush_remaining`'s `\n`-join over
1547    // consecutive entries).
1548    //
1549    // EXCEPTION (issue #2091): this final entry is itself eligible for
1550    // suppression like any other — if the transcript's unread tail ends
1551    // with an unterminated fragment-bearing segment that resolves empty
1552    // (no following `Newline`), `suppressed` is `true` here too, and
1553    // `resolve_lines` drops this entry from its `Vec` entirely rather than
1554    // keeping it as a `("", [])` placeholder. When that happens, the
1555    // trailing-`\n`-via-empty-final-entry guarantee this comment describes
1556    // does NOT hold for the preceding real line — there is no longer a
1557    // placeholder entry left for a caller's join loop to add a separator
1558    // before. This is accepted, not additionally special-cased: it only
1559    // arises when the story's last visible output is itself an empty
1560    // `content`/Fragment interpolation, which is precisely the case this
1561    // issue suppresses.
1562    trim_in_place(&mut current_text);
1563    let suppressed = current_text.is_empty() && current_tags.is_empty() && saw_fragment_ref;
1564    (
1565        current_text,
1566        current_tags,
1567        suppressed,
1568        current_element,
1569        current_source,
1570    )
1571}
1572
1573/// Create a minimal `Program` for tests that only use `Text`/`Newline`/`Glue`.
1574#[cfg(test)]
1575fn test_dummy_program() -> Program {
1576    use std::collections::HashMap;
1577    Program {
1578        link: crate::program::LinkTables::default(),
1579        containers: vec![],
1580        address_map: HashMap::new(),
1581        scope_ids: vec![],
1582        source_checksum: 0,
1583        globals: vec![],
1584        global_map: HashMap::new(),
1585        name_table: vec![],
1586        address_by_path: HashMap::new(),
1587        container_paths: HashMap::new(),
1588        root_idx: 0,
1589        list_literals: vec![],
1590        literal_pool: vec![],
1591        list_item_map: HashMap::new(),
1592        list_defs: vec![],
1593        list_def_map: HashMap::new(),
1594        external_fns: HashMap::new(),
1595        local_scope_defaults: Vec::new(),
1596        struct_shapes: Vec::new(),
1597        private_defs: Vec::new(),
1598        alias_table: Vec::new(),
1599        debug_info: None,
1600    }
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605    use super::*;
1606
1607    /// The streaming resolver `take_first_line` uses must agree with the
1608    /// batch resolver it replaced, entry for entry: same first line, and the
1609    /// carried element state is the element of the entry that follows it.
1610    /// Over the shapes the streaming path meets — plain lines, glue across
1611    /// a newline, tags, and an element run that ends right after the line.
1612    #[test]
1613    fn first_line_resolver_matches_batch_resolver() {
1614        let program = test_dummy_program();
1615        let seed = |k: &str, v: &str| {
1616            let mut m = BTreeMap::new();
1617            m.insert(k.to_string(), v.to_string());
1618            m
1619        };
1620        let cases: Vec<(Vec<OutputPart>, BTreeMap<String, String>)> = vec![
1621            (
1622                vec![
1623                    OutputPart::Text("hello ".to_string()),
1624                    OutputPart::Text(" world".to_string()),
1625                    OutputPart::Newline,
1626                    OutputPart::Text("next".to_string()),
1627                ],
1628                BTreeMap::new(),
1629            ),
1630            (
1631                vec![
1632                    OutputPart::Text("a".to_string()),
1633                    OutputPart::Newline,
1634                    OutputPart::Glue,
1635                    OutputPart::Text("b".to_string()),
1636                    OutputPart::Newline,
1637                ],
1638                BTreeMap::new(),
1639            ),
1640            (
1641                vec![
1642                    OutputPart::Tag("t".to_string()),
1643                    OutputPart::Text("  tagged  ".to_string()),
1644                    OutputPart::Newline,
1645                ],
1646                BTreeMap::new(),
1647            ),
1648            (
1649                vec![
1650                    OutputPart::ElementAttach("k".to_string(), "v".to_string()),
1651                    OutputPart::Text("in run".to_string()),
1652                    OutputPart::Newline,
1653                    OutputPart::ElementAttachEnd,
1654                ],
1655                seed("outer", "x"),
1656            ),
1657            (
1658                vec![
1659                    OutputPart::Text("carried".to_string()),
1660                    OutputPart::Newline,
1661                    OutputPart::ElementAttach("k2".to_string(), "v2".to_string()),
1662                ],
1663                seed("outer", "x"),
1664            ),
1665        ];
1666        for (parts, seed_element) in cases {
1667            let mut remove = vec![false; parts.len()];
1668            mark_glue_removals(&parts, &mut remove);
1669            // The slice `take_first_line` would cut: through the first
1670            // newline the glue marks leave standing.
1671            let split_at = parts
1672                .iter()
1673                .enumerate()
1674                .position(|(i, p)| matches!(p, OutputPart::Newline) && !remove[i])
1675                .expect("every case carries a kept newline");
1676            let slice = &parts[..=split_at];
1677            let marks = &remove[..=split_at];
1678            let batch = resolve_lines_annotated_marked(
1679                slice,
1680                marks,
1681                seed_element.clone(),
1682                &program,
1683                &[],
1684                None,
1685                &Fragments::default(),
1686            );
1687            let (line, next_element) = resolve_first_line_annotated(
1688                slice,
1689                marks,
1690                seed_element,
1691                &program,
1692                &[],
1693                None,
1694                &Fragments::default(),
1695            );
1696            assert_eq!(
1697                batch.len(),
1698                2,
1699                "one line plus the trailing entry: {parts:?}"
1700            );
1701            assert_eq!(line, batch[0], "first line differs: {parts:?}");
1702            assert_eq!(
1703                next_element, batch[1].3,
1704                "carried element differs: {parts:?}"
1705            );
1706        }
1707    }
1708
1709    /// Test helpers — `OutputBuffer` methods that need resolution context.
1710    /// Tests only use Text/Newline/Glue, so we pass an empty program.
1711    impl OutputBuffer {
1712        fn test_flush_lines(&mut self) -> Vec<(String, Vec<String>)> {
1713            let p = test_dummy_program();
1714            // Element-attachment data (issue #2108) is dropped here — none
1715            // of these pre-existing tests exercise attach conventions, and
1716            // widening every existing `(text, tags)` assertion in this
1717            // module for a field they never populate would just be noise.
1718            // `crates/brink-runtime/tests/element.rs` exercises the real
1719            // per-line element data end to end instead.
1720            self.flush_lines(&p, &[], None)
1721                .into_iter()
1722                .map(|(text, tags, _element, _source)| (text, tags))
1723                .collect()
1724        }
1725
1726        fn test_take_first_line(&mut self) -> Option<(String, Vec<String>)> {
1727            let p = test_dummy_program();
1728            self.take_first_line(&p, &[], None)
1729                .map(|(text, tags, _element, _source)| (text, tags))
1730        }
1731
1732        fn test_end_capture(&mut self) -> Option<String> {
1733            let p = test_dummy_program();
1734            self.end_capture(&p, &[], None)
1735        }
1736    }
1737
1738    #[test]
1739    fn simple_text() {
1740        let mut buf = OutputBuffer::new();
1741        buf.push_text("hello");
1742        assert_eq!(buf.flush(), "hello");
1743    }
1744
1745    #[test]
1746    fn text_with_newline() {
1747        let mut buf = OutputBuffer::new();
1748        buf.push_text("hello");
1749        buf.push_newline();
1750        buf.push_text("world");
1751        assert_eq!(buf.flush(), "hello\nworld");
1752    }
1753
1754    #[test]
1755    fn glue_removes_newline() {
1756        let mut buf = OutputBuffer::new();
1757        buf.push_text("hello");
1758        buf.push_newline();
1759        buf.push_glue();
1760        buf.push_text("world");
1761        assert_eq!(buf.flush(), "helloworld");
1762    }
1763
1764    #[test]
1765    fn glue_preserves_leading_whitespace_in_text() {
1766        let mut buf = OutputBuffer::new();
1767        buf.push_text("hello");
1768        buf.push_newline();
1769        buf.push_glue();
1770        buf.push_text("  world");
1771        assert_eq!(buf.flush(), "hello  world");
1772    }
1773
1774    #[test]
1775    fn double_flush_is_empty() {
1776        let mut buf = OutputBuffer::new();
1777        buf.push_text("hello");
1778        let _ = buf.flush();
1779        assert_eq!(buf.flush(), "");
1780    }
1781
1782    #[test]
1783    fn leading_newline_suppressed() {
1784        let mut buf = OutputBuffer::new();
1785        buf.push_newline();
1786        buf.push_text("hello");
1787        assert_eq!(buf.flush(), "hello");
1788    }
1789
1790    /// Leading whitespace-only text at the start of output (no prior content)
1791    /// should be suppressed, just like leading newlines are suppressed.
1792    /// This happens after choice selection: choice bodies start with `"^ "`.
1793    #[test]
1794    fn leading_whitespace_only_text_suppressed() {
1795        let mut buf = OutputBuffer::new();
1796        buf.push_text(" ");
1797        buf.push_text("hello");
1798        assert_eq!(buf.flush(), "hello");
1799    }
1800
1801    /// Leading whitespace-only text after a flush should also be suppressed.
1802    /// Adjacent whitespace at text boundaries should collapse.
1803    /// E.g., start content "Hello " + inner content " right back" → "Hello right back".
1804    #[test]
1805    fn adjacent_whitespace_collapsed() {
1806        let mut buf = OutputBuffer::new();
1807        buf.push_text("Hello ");
1808        buf.push_text(" right back");
1809        assert_eq!(buf.flush(), "Hello right back");
1810    }
1811
1812    #[test]
1813    fn leading_whitespace_after_flush_suppressed() {
1814        let mut buf = OutputBuffer::new();
1815        buf.push_text("first");
1816        let _ = buf.flush();
1817        buf.push_text("  ");
1818        buf.push_text("second");
1819        assert_eq!(buf.flush(), "second");
1820    }
1821
1822    #[test]
1823    fn duplicate_newline_suppressed() {
1824        let mut buf = OutputBuffer::new();
1825        buf.push_text("hello");
1826        buf.push_newline();
1827        buf.push_newline();
1828        buf.push_text("world");
1829        assert_eq!(buf.flush(), "hello\nworld");
1830    }
1831
1832    #[test]
1833    fn leading_newline_after_flush_suppressed() {
1834        let mut buf = OutputBuffer::new();
1835        buf.push_text("first");
1836        let _ = buf.flush();
1837        // After flush, buffer is empty again — leading newline should be suppressed.
1838        buf.push_newline();
1839        buf.push_text("second");
1840        assert_eq!(buf.flush(), "second");
1841    }
1842
1843    #[test]
1844    fn begin_end_capture_basic() {
1845        let mut buf = OutputBuffer::new();
1846        buf.push_text("before");
1847        buf.begin_capture();
1848        buf.push_text("captured");
1849        let result = buf.test_end_capture();
1850        assert_eq!(result, Some("captured".to_owned()));
1851        assert_eq!(buf.flush(), "before");
1852    }
1853
1854    #[test]
1855    fn nested_captures() {
1856        let mut buf = OutputBuffer::new();
1857        buf.push_text("outer");
1858        buf.begin_capture();
1859        buf.push_text("middle");
1860        buf.begin_capture();
1861        buf.push_text("inner");
1862        let inner = buf.test_end_capture();
1863        assert_eq!(inner, Some("inner".to_owned()));
1864        let middle = buf.test_end_capture();
1865        assert_eq!(middle, Some("middle".to_owned()));
1866        assert_eq!(buf.flush(), "outer");
1867    }
1868
1869    #[test]
1870    fn capture_with_glue() {
1871        let mut buf = OutputBuffer::new();
1872        buf.begin_capture();
1873        buf.push_text("hello");
1874        buf.push_newline();
1875        buf.push_glue();
1876        buf.push_text(" world");
1877        let result = buf.test_end_capture();
1878        assert_eq!(result, Some("hello world".to_owned()));
1879    }
1880
1881    #[test]
1882    fn end_capture_no_checkpoint_returns_none() {
1883        let mut buf = OutputBuffer::new();
1884        buf.push_text("hello");
1885        assert_eq!(buf.test_end_capture(), None);
1886    }
1887
1888    #[test]
1889    fn has_content_respects_checkpoint() {
1890        let mut buf = OutputBuffer::new();
1891        buf.push_text("before");
1892        buf.begin_capture();
1893        // No content after the checkpoint.
1894        assert!(!buf.has_content());
1895        buf.push_text("after");
1896        assert!(buf.has_content());
1897    }
1898
1899    /// Glue should eat the following newline, not just the preceding one.
1900    /// Pattern: `<>-<>` where glue appears on both sides of the dash.
1901    #[test]
1902    fn glue_eats_following_newline() {
1903        let mut buf = OutputBuffer::new();
1904        buf.push_text("fifty");
1905        buf.push_newline();
1906        buf.push_glue();
1907        buf.push_text("-");
1908        buf.push_glue();
1909        buf.push_newline();
1910        buf.push_text("eight");
1911        assert_eq!(buf.flush(), "fifty-eight");
1912    }
1913
1914    /// Trailing whitespace before a newline should be trimmed.
1915    /// Pattern: `A {f():B}⏎X` where `f()` returns false — the space after
1916    /// "A" becomes trailing whitespace when the inline expression produces
1917    /// no output.
1918    #[test]
1919    fn trailing_whitespace_before_newline_trimmed() {
1920        let mut buf = OutputBuffer::new();
1921        buf.push_text("A ");
1922        buf.push_newline();
1923        buf.push_text("X");
1924        assert_eq!(buf.flush(), "A\nX");
1925    }
1926
1927    /// Glue should NOT trim leading whitespace from text content.
1928    /// Pattern: `Some <>⏎content<> with glue.`
1929    /// The space in " with glue." is content, not indentation.
1930    #[test]
1931    fn glue_preserves_text_whitespace() {
1932        let mut buf = OutputBuffer::new();
1933        buf.push_text("Some ");
1934        buf.push_glue();
1935        buf.push_newline();
1936        buf.push_text("content");
1937        buf.push_glue();
1938        buf.push_text(" with glue.");
1939        assert_eq!(buf.flush(), "Some content with glue.");
1940    }
1941
1942    /// Glue should skip past whitespace-only text to find the preceding newline.
1943    /// Pattern: `a\n" "<>b` — the `" "` is whitespace-only and should not block
1944    /// the glue from removing the newline — and (issue #3507) it goes WITH
1945    /// the newline: ink's `TrimNewlinesFromOutputStream` removes the trailing
1946    /// newline and every whitespace-only string after it, so `a` /
1947    /// `{false:x} <>` / `b` prints `ab` (inkjs 2.4.0 via
1948    /// `tools/inkjs-oracle`). This test used to pin `a b`, which was the
1949    /// divergence.
1950    #[test]
1951    fn glue_skips_whitespace_only_text_to_find_newline() {
1952        let mut buf = OutputBuffer::new();
1953        buf.push_text("a");
1954        buf.push_newline();
1955        buf.push_text(" ");
1956        buf.push_glue();
1957        buf.push_text("b");
1958        assert_eq!(buf.flush(), "ab");
1959    }
1960
1961    /// Issue #3507: a `Spring` between a glue-removed newline and the glue
1962    /// is whitespace after that newline and dies with it (`ab`); with
1963    /// content on the line the newline is not trailing, so the spring's
1964    /// space survives (`0 world`).
1965    #[test]
1966    fn spring_before_glue_survives_only_after_line_content() {
1967        let mut buf = OutputBuffer::new();
1968        buf.push_text("a");
1969        buf.push_newline();
1970        buf.push_spring();
1971        buf.push_glue();
1972        buf.push_text("b");
1973        assert_eq!(buf.flush(), "ab");
1974
1975        let mut buf = OutputBuffer::new();
1976        buf.push_text("a");
1977        buf.push_newline();
1978        buf.push_text("0");
1979        buf.push_spring();
1980        buf.push_glue();
1981        buf.push_text("world");
1982        assert_eq!(buf.flush(), "a\n0 world");
1983    }
1984
1985    // ── flush_lines tests ────────────────────────────────────────────
1986
1987    /// Tags should associate with the line they appear on.
1988    #[test]
1989    fn flush_lines_associates_tags_with_lines() {
1990        let mut buf = OutputBuffer::new();
1991        buf.push_text("line one");
1992        buf.push_newline();
1993        buf.push_text("line two");
1994        buf.push_tag("my_tag".to_string());
1995        buf.push_newline();
1996        buf.push_text("line three");
1997        let lines = buf.test_flush_lines();
1998        assert_eq!(lines.len(), 3);
1999        assert_eq!(lines[0].0, "line one");
2000        assert!(lines[0].1.is_empty());
2001        assert_eq!(lines[1].0, "line two");
2002        assert_eq!(lines[1].1, vec!["my_tag"]);
2003        assert_eq!(lines[2].0, "line three");
2004        assert!(lines[2].1.is_empty());
2005    }
2006
2007    /// Tags on the last line (no trailing newline) should still be captured.
2008    #[test]
2009    fn flush_lines_tag_on_last_line() {
2010        let mut buf = OutputBuffer::new();
2011        buf.push_text("only line");
2012        buf.push_tag("t".to_string());
2013        let lines = buf.test_flush_lines();
2014        assert_eq!(lines.len(), 1);
2015        assert_eq!(lines[0].0, "only line");
2016        assert_eq!(lines[0].1, vec!["t"]);
2017    }
2018
2019    /// `flush_lines` should resolve glue the same as `flush`.
2020    #[test]
2021    fn flush_lines_resolves_glue() {
2022        let mut buf = OutputBuffer::new();
2023        buf.push_text("hello");
2024        buf.push_newline();
2025        buf.push_glue();
2026        buf.push_text(" world");
2027        let lines = buf.test_flush_lines();
2028        assert_eq!(lines.len(), 1);
2029        assert_eq!(lines[0].0, "hello world");
2030    }
2031
2032    /// Flushing an empty buffer should return no lines.
2033    /// A spurious `[("", [])]` from an empty buffer causes leading `\n`
2034    /// when `step_with` calls `flush_lines` multiple times (e.g., before
2035    /// auto-selecting invisible default choices).
2036    #[test]
2037    fn flush_lines_empty_buffer_returns_no_lines() {
2038        let mut buf = OutputBuffer::new();
2039        let lines = buf.test_flush_lines();
2040        assert!(
2041            lines.is_empty(),
2042            "empty buffer should produce no lines, got: {lines:?}"
2043        );
2044    }
2045
2046    // ── has_completed_line / take_first_line tests ──────────────────
2047
2048    #[test]
2049    fn has_completed_line_empty() {
2050        let buf = OutputBuffer::new();
2051        assert!(!buf.has_completed_line());
2052    }
2053
2054    #[test]
2055    fn has_completed_line_text_only() {
2056        let mut buf = OutputBuffer::new();
2057        buf.push_text("hello");
2058        assert!(!buf.has_completed_line());
2059    }
2060
2061    #[test]
2062    fn has_completed_line_text_newline_only() {
2063        let mut buf = OutputBuffer::new();
2064        buf.push_text("hello");
2065        buf.push_newline();
2066        // No content after the newline → not committed.
2067        assert!(!buf.has_completed_line());
2068    }
2069
2070    #[test]
2071    fn has_completed_line_text_newline_text() {
2072        let mut buf = OutputBuffer::new();
2073        buf.push_text("hello");
2074        buf.push_newline();
2075        buf.push_text("world");
2076        assert!(buf.has_completed_line());
2077    }
2078
2079    #[test]
2080    fn has_completed_line_glue_eats_newline() {
2081        let mut buf = OutputBuffer::new();
2082        buf.push_text("hello");
2083        buf.push_newline();
2084        buf.push_glue();
2085        buf.push_text("world");
2086        // Glue eats the newline → no committed newline.
2087        assert!(!buf.has_completed_line());
2088    }
2089
2090    #[test]
2091    fn has_completed_line_during_capture() {
2092        let mut buf = OutputBuffer::new();
2093        buf.push_text("hello");
2094        buf.push_newline();
2095        buf.push_text("world");
2096        buf.begin_capture();
2097        // Active capture → not available for line extraction.
2098        assert!(!buf.has_completed_line());
2099    }
2100
2101    #[test]
2102    fn take_first_line_basic() {
2103        let mut buf = OutputBuffer::new();
2104        buf.push_text("hello");
2105        buf.push_newline();
2106        buf.push_text("world");
2107
2108        let result = buf.test_take_first_line();
2109        assert!(result.is_some());
2110        let (text, tags) = result.unwrap();
2111        assert_eq!(text, "hello\n");
2112        assert!(tags.is_empty());
2113
2114        // Remainder should produce "world" when flushed.
2115        assert_eq!(buf.flush(), "world");
2116    }
2117
2118    #[test]
2119    fn take_first_line_with_tags() {
2120        let mut buf = OutputBuffer::new();
2121        buf.push_text("tagged line");
2122        buf.push_tag("my_tag".to_string());
2123        buf.push_newline();
2124        buf.push_text("next line");
2125
2126        let (text, tags) = buf.test_take_first_line().unwrap();
2127        assert_eq!(text, "tagged line\n");
2128        assert_eq!(tags, vec!["my_tag"]);
2129
2130        assert_eq!(buf.flush(), "next line");
2131    }
2132
2133    #[test]
2134    fn take_first_line_multiple_lines() {
2135        let mut buf = OutputBuffer::new();
2136        buf.push_text("line one");
2137        buf.push_newline();
2138        buf.push_text("line two");
2139        buf.push_newline();
2140        buf.push_text("line three");
2141
2142        let (text1, _) = buf.test_take_first_line().unwrap();
2143        assert_eq!(text1, "line one\n");
2144
2145        let (text2, _) = buf.test_take_first_line().unwrap();
2146        assert_eq!(text2, "line two\n");
2147
2148        // Only "line three" remains, no newline after it → no completed line.
2149        assert!(!buf.has_completed_line());
2150        assert_eq!(buf.flush(), "line three");
2151    }
2152
2153    #[test]
2154    fn take_first_line_matches_flush_lines() {
2155        // Verify take_first_line produces the same first line as flush_lines.
2156        let parts = |buf: &mut OutputBuffer| {
2157            buf.push_text("A ");
2158            buf.push_tag("t1".to_string());
2159            buf.push_newline();
2160            buf.push_text("B");
2161            buf.push_newline();
2162            buf.push_text("C");
2163        };
2164
2165        let mut buf1 = OutputBuffer::new();
2166        parts(&mut buf1);
2167        let all_lines = buf1.test_flush_lines();
2168        let first_from_flush = &all_lines[0].0;
2169
2170        let mut buf2 = OutputBuffer::new();
2171        parts(&mut buf2);
2172        let (first_from_take, tags) = buf2.test_take_first_line().unwrap();
2173        // take_first_line appends \n; strip it for comparison.
2174        let first_trimmed = first_from_take.trim_end_matches('\n');
2175
2176        assert_eq!(first_trimmed, first_from_flush);
2177        assert_eq!(tags, all_lines[0].1);
2178    }
2179
2180    #[test]
2181    fn take_first_line_glue_preserves_subsequent() {
2182        // Glue eats the first newline; second newline survives.
2183        let mut buf = OutputBuffer::new();
2184        buf.push_text("hello");
2185        buf.push_newline();
2186        buf.push_glue();
2187        buf.push_text(" world");
2188        buf.push_newline();
2189        buf.push_text("next");
2190
2191        let (text, _) = buf.test_take_first_line().unwrap();
2192        assert_eq!(text, "hello world\n");
2193        assert_eq!(buf.flush(), "next");
2194    }
2195
2196    #[test]
2197    fn take_first_line_none_when_empty() {
2198        let mut buf = OutputBuffer::new();
2199        assert!(buf.test_take_first_line().is_none());
2200    }
2201
2202    #[test]
2203    fn take_first_line_none_when_no_newline() {
2204        let mut buf = OutputBuffer::new();
2205        buf.push_text("no newline");
2206        assert!(buf.test_take_first_line().is_none());
2207    }
2208
2209    // ── resolve_line_ref template collapsing tests ────────────────────
2210
2211    /// Build a minimal `Program` with one container (`scope_table_idx` = 0)
2212    /// and a line table with a single template entry, then resolve it.
2213    fn resolve_template(parts: Vec<LinePart>, slots: &[Value]) -> String {
2214        use crate::program::LinkedContainer;
2215        use brink_format::{CountingFlags, DefinitionId, DefinitionTag, LineEntry, LineFlags};
2216        use std::collections::HashMap;
2217
2218        let id = DefinitionId::new(DefinitionTag::Address, 0);
2219        let program = Program {
2220            link: crate::program::LinkTables::default(),
2221            containers: vec![LinkedContainer {
2222                id,
2223                bytecode: vec![],
2224                counting_flags: CountingFlags::empty(),
2225                path_hash: 0,
2226                param_count: 0,
2227                params: Vec::new(),
2228                scope_table_idx: 0,
2229                scope_id: id,
2230            }],
2231            address_map: HashMap::new(),
2232            scope_ids: vec![id],
2233            source_checksum: 0,
2234            globals: vec![],
2235            global_map: HashMap::new(),
2236            name_table: vec![],
2237            address_by_path: HashMap::new(),
2238            container_paths: HashMap::new(),
2239            root_idx: 0,
2240            list_literals: vec![],
2241            literal_pool: vec![],
2242            list_item_map: HashMap::new(),
2243            list_defs: vec![],
2244            list_def_map: HashMap::new(),
2245            external_fns: HashMap::new(),
2246            local_scope_defaults: Vec::new(),
2247            struct_shapes: Vec::new(),
2248            private_defs: Vec::new(),
2249            alias_table: Vec::new(),
2250            debug_info: None,
2251        };
2252
2253        let line_tables = vec![vec![LineEntry {
2254            content: LineContent::Template(parts),
2255            source_hash: 0,
2256            flags: LineFlags::empty(),
2257            audio_ref: None,
2258            slot_info: vec![],
2259            source_location: None,
2260        }]];
2261
2262        resolve_line_ref(
2263            &program,
2264            &line_tables,
2265            0,
2266            0,
2267            slots,
2268            None,
2269            &Fragments::default(),
2270        )
2271    }
2272
2273    #[test]
2274    fn template_collapses_double_space_from_empty_slot() {
2275        let result = resolve_template(
2276            vec![
2277                LinePart::Literal("Hello ".into()),
2278                LinePart::Slot(0),
2279                LinePart::Literal(" world".into()),
2280            ],
2281            &[Value::Null],
2282        );
2283        assert_eq!(result, "Hello world");
2284    }
2285
2286    #[test]
2287    fn template_preserves_spaces_with_nonempty_slot() {
2288        let result = resolve_template(
2289            vec![
2290                LinePart::Literal("Hello ".into()),
2291                LinePart::Slot(0),
2292                LinePart::Literal(" world".into()),
2293            ],
2294            &[Value::String("dear".into())],
2295        );
2296        assert_eq!(result, "Hello dear world");
2297    }
2298
2299    #[test]
2300    fn template_multiple_empty_slots_collapse() {
2301        let result = resolve_template(
2302            vec![
2303                LinePart::Literal("a ".into()),
2304                LinePart::Slot(0),
2305                LinePart::Literal(" ".into()),
2306                LinePart::Slot(1),
2307                LinePart::Literal(" b".into()),
2308            ],
2309            &[Value::Null, Value::Null],
2310        );
2311        assert_eq!(result, "a b");
2312    }
2313
2314    #[test]
2315    fn template_empty_string_slot_same_as_null() {
2316        let result = resolve_template(
2317            vec![
2318                LinePart::Literal("Hello ".into()),
2319                LinePart::Slot(0),
2320                LinePart::Literal(" world".into()),
2321            ],
2322            &[Value::String("".into())],
2323        );
2324        assert_eq!(result, "Hello world");
2325    }
2326
2327    // ── Inline markup spans (#1716, docs/prose-dialect-spec.md §4) ─────
2328    //
2329    // No structured `Part::Span` consumer surface exists yet (§7/§9.1 ⏳)
2330    // — a span resolves to its children's concatenated text, tag name/
2331    // attrs stripped, recursing through the same `resolve_line_parts` a
2332    // plain Template does.
2333
2334    #[test]
2335    fn span_resolves_to_its_children_text_tag_stripped() {
2336        let result = resolve_template(
2337            vec![
2338                LinePart::Literal("Hello ".into()),
2339                LinePart::Span {
2340                    name: "wave".into(),
2341                    attrs: vec![],
2342                    children: vec![LinePart::Literal("world".into())],
2343                },
2344            ],
2345            &[],
2346        );
2347        assert_eq!(result, "Hello world");
2348    }
2349
2350    #[test]
2351    fn a_self_closing_span_with_no_children_resolves_to_nothing() {
2352        let result = resolve_template(
2353            vec![
2354                LinePart::Literal("Bell tolls. ".into()),
2355                LinePart::Span {
2356                    name: "pause".into(),
2357                    attrs: vec![],
2358                    children: vec![],
2359                },
2360                LinePart::Literal(" Door slams.".into()),
2361            ],
2362            &[],
2363        );
2364        assert_eq!(result, "Bell tolls. Door slams.");
2365    }
2366
2367    #[test]
2368    fn a_span_containing_a_slot_resolves_the_slot() {
2369        let result = resolve_template(
2370            vec![LinePart::Span {
2371                name: "b".into(),
2372                attrs: vec![],
2373                children: vec![LinePart::Literal("hello ".into()), LinePart::Slot(0)],
2374            }],
2375            &[Value::String("Fogg".into())],
2376        );
2377        assert_eq!(result, "hello Fogg");
2378    }
2379
2380    #[test]
2381    fn nested_spans_resolve_recursively() {
2382        let result = resolve_template(
2383            vec![LinePart::Span {
2384                name: "b".into(),
2385                attrs: vec![],
2386                children: vec![LinePart::Span {
2387                    name: "i".into(),
2388                    attrs: vec![],
2389                    children: vec![LinePart::Literal("hi".into())],
2390                }],
2391            }],
2392            &[],
2393        );
2394        assert_eq!(result, "hi");
2395    }
2396
2397    // ── B4 display-boundary forgiveness (`docs/stdlib-spec.md` §1.6b) ──
2398
2399    /// A final-`None` template slot renders as nothing — the surrounding
2400    /// whitespace collapses exactly like the pre-existing `Null`/empty-
2401    /// string slot cases above.
2402    #[test]
2403    fn template_none_option_slot_renders_as_nothing() {
2404        let result = resolve_template(
2405            vec![
2406                LinePart::Literal("Hello ".into()),
2407                LinePart::Slot(0),
2408                LinePart::Literal(" world".into()),
2409            ],
2410            &[Value::none()],
2411        );
2412        assert_eq!(result, "Hello world");
2413    }
2414
2415    /// `Some(v)` at the same slot position is unaffected by the boundary —
2416    /// still `some(<v>)`, the F28 total rendering `stringify` gives it.
2417    #[test]
2418    fn template_some_option_slot_renders_totally() {
2419        let result = resolve_template(
2420            vec![LinePart::Literal("val: ".into()), LinePart::Slot(0)],
2421            &[Value::some(Value::Int(3))],
2422        );
2423        assert_eq!(result, "val: some(3)");
2424    }
2425
2426    /// A bare `OutputPart::ValueRef` (the `EmitValue`/unrecognized-content
2427    /// path, not a template slot) gets the same forgiveness — and the
2428    /// surrounding whitespace collapses across it exactly like it already
2429    /// does across an eagerly-dropped `Value::Null` or an empty string
2430    /// (`adjacent_whitespace_collapsed`, above): "before " + (nothing) +
2431    /// " after" reads as one collapsed space, not two.
2432    #[test]
2433    fn value_ref_none_option_renders_as_nothing() {
2434        let mut buf = OutputBuffer::new();
2435        buf.push_text("before ");
2436        buf.push_value_ref(Value::none());
2437        buf.push_text(" after");
2438        assert_eq!(buf.flush(), "before after");
2439    }
2440
2441    /// Traceability rider (§1.6b): the append-only transcript is never
2442    /// eagerly resolved (`docs/runtime-restructuring-spec.md`'s
2443    /// deferred-resolution model) — a forgiven `None`-render still shows up
2444    /// as `Value::OptionVal(None)` in `transcript()`, distinct from a slot
2445    /// that carried no value at all. Resolving to text loses the
2446    /// information; the structural transcript never does.
2447    #[test]
2448    fn none_render_is_traceable_in_the_raw_transcript() {
2449        let mut buf = OutputBuffer::new();
2450        buf.push_value_ref(Value::none());
2451        assert!(
2452            buf.transcript()
2453                .iter()
2454                .any(|p| matches!(p, OutputPart::ValueRef(Value::OptionVal(None)))),
2455            "the raw None value must survive in the transcript: {:?}",
2456            buf.transcript()
2457        );
2458        // Resolving it, separately, gives the forgiven empty text.
2459        assert_eq!(buf.flush(), "");
2460    }
2461
2462    /// A leading `None`-rendering value must not count as content for
2463    /// leading-newline suppression — otherwise a story that opens with a
2464    /// forgiven interpolation would get a spurious blank line before its
2465    /// real content.
2466    #[test]
2467    fn leading_none_option_value_does_not_block_newline_suppression() {
2468        let mut buf = OutputBuffer::new();
2469        buf.push_value_ref(Value::none());
2470        buf.push_newline();
2471        buf.push_text("hello");
2472        assert_eq!(buf.flush(), "hello");
2473    }
2474
2475    /// A `None`-rendering value between glue and its target newline must
2476    /// not block the glue scan — it passes through like whitespace-only
2477    /// text, matching `mark_glue_removals`'s existing arms.
2478    #[test]
2479    fn none_option_value_does_not_block_glue_scan() {
2480        let mut buf = OutputBuffer::new();
2481        buf.push_text("hello");
2482        buf.push_newline();
2483        buf.push_value_ref(Value::none());
2484        buf.push_glue();
2485        buf.push_text("world");
2486        assert_eq!(buf.flush(), "helloworld");
2487    }
2488
2489    // ── #2091: suppress a blank line from an empty content/Fragment capture ──
2490    //
2491    // A `block`-capturing handler (issue #1839) whose captured run is empty —
2492    // e.g. a cue immediately followed by a parenthetical, so
2493    // `hir::lower_native::element::capture_block` finds zero interior lines
2494    // — still binds its `content`-typed parameter to a real (empty)
2495    // `Value::FragmentRef`. Interpolating that alone on a template line
2496    // (`{body}` in a prose-ground handler body) used to render its own
2497    // visible blank line. These tests exercise the fix directly against the
2498    // output-resolution layer, independent of the full compiler pipeline
2499    // (see `tests/tier1-native/conventions-screenplay-preset/` for the e2e
2500    // golden fixture this same fix corrects).
2501
2502    /// Build a minimal one-container `Program` plus a matching line table
2503    /// from a caller-supplied list of `LineEntry`s (indices become
2504    /// `line_idx`), for `resolve_lines`/`take_first_line` tests that need
2505    /// more than `resolve_template`'s single entry.
2506    fn program_with_line_table(entries: Vec<LineEntry>) -> (Program, Vec<Vec<LineEntry>>) {
2507        use crate::program::LinkedContainer;
2508        use brink_format::{CountingFlags, DefinitionId, DefinitionTag};
2509        use std::collections::HashMap;
2510
2511        let id = DefinitionId::new(DefinitionTag::Address, 0);
2512        let program = Program {
2513            link: crate::program::LinkTables::default(),
2514            containers: vec![LinkedContainer {
2515                id,
2516                bytecode: vec![],
2517                counting_flags: CountingFlags::empty(),
2518                path_hash: 0,
2519                param_count: 0,
2520                params: Vec::new(),
2521                scope_table_idx: 0,
2522                scope_id: id,
2523            }],
2524            address_map: HashMap::new(),
2525            scope_ids: vec![id],
2526            source_checksum: 0,
2527            globals: vec![],
2528            global_map: HashMap::new(),
2529            name_table: vec![],
2530            address_by_path: HashMap::new(),
2531            container_paths: HashMap::new(),
2532            root_idx: 0,
2533            list_literals: vec![],
2534            literal_pool: vec![],
2535            list_item_map: HashMap::new(),
2536            list_defs: vec![],
2537            list_def_map: HashMap::new(),
2538            external_fns: HashMap::new(),
2539            local_scope_defaults: Vec::new(),
2540            struct_shapes: Vec::new(),
2541            private_defs: Vec::new(),
2542            alias_table: Vec::new(),
2543            debug_info: None,
2544        };
2545        (program, vec![entries])
2546    }
2547
2548    fn plain_entry(s: &str) -> LineEntry {
2549        LineEntry {
2550            content: LineContent::Plain(s.to_string()),
2551            source_hash: 0,
2552            flags: brink_format::LineFlags::from_plain(s),
2553            audio_ref: None,
2554            slot_info: vec![],
2555            source_location: None,
2556        }
2557    }
2558
2559    fn one_slot_template_entry() -> LineEntry {
2560        LineEntry {
2561            content: LineContent::Template(vec![LinePart::Slot(0)]),
2562            source_hash: 0,
2563            // A Slot always defeats the compile-time conservative flags —
2564            // see `LineFlags::from_template`'s own doc/tests.
2565            flags: brink_format::LineFlags::empty(),
2566            audio_ref: None,
2567            slot_info: vec![],
2568            source_location: None,
2569        }
2570    }
2571
2572    fn line_ref(line_idx: u16, slots: Vec<Value>, flags: brink_format::LineFlags) -> OutputPart {
2573        OutputPart::LineRef {
2574            container_idx: 0,
2575            line_idx,
2576            slots,
2577            flags,
2578        }
2579    }
2580
2581    #[test]
2582    fn resolve_lines_suppresses_a_blank_line_from_an_empty_content_capture() {
2583        // line 0: "VENDOR", line 1: `{body}` (the block-capture receiver),
2584        // line 2: "(hushed)" — matches the shape of the real regression
2585        // (`tests/tier1-native/conventions-screenplay-preset/story.brink`).
2586        let (program, line_tables) = program_with_line_table(vec![
2587            plain_entry("VENDOR"),
2588            one_slot_template_entry(),
2589            plain_entry("(hushed)"),
2590        ]);
2591        // The captured block was empty: a real, present `Fragment` with no
2592        // parts — not an omitted line-table entry (issue #2091's own "what
2593        // happens to the line-table entry" question: present-but-empty).
2594        let fragments = Fragments::from(vec![Fragment {
2595            parts: vec![],
2596            tags: vec![],
2597        }]);
2598
2599        let parts = vec![
2600            line_ref(0, vec![], brink_format::LineFlags::from_plain("VENDOR")),
2601            OutputPart::Newline,
2602            line_ref(
2603                1,
2604                vec![Value::FragmentRef(0)],
2605                brink_format::LineFlags::empty(),
2606            ),
2607            OutputPart::Newline,
2608            line_ref(2, vec![], brink_format::LineFlags::from_plain("(hushed)")),
2609        ];
2610
2611        // Element-attachment data (issue #2108) is dropped here — these
2612        // pre-existing fixtures don't exercise attach conventions.
2613        let lines: Vec<(String, Vec<String>)> =
2614            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2615                .into_iter()
2616                .map(|(text, tags, _element, _source)| (text, tags))
2617                .collect();
2618        assert_eq!(
2619            lines,
2620            vec![
2621                ("VENDOR".to_string(), Vec::<String>::new()),
2622                ("(hushed)".to_string(), Vec::<String>::new()),
2623            ],
2624            "an empty content/Fragment capture must not render its own blank \
2625             line between real content: {lines:?}"
2626        );
2627    }
2628
2629    /// Reviewer finding (PR #2140, issue #2091): the scope is NOT limited to
2630    /// issue #1839's `block`-capture receiver. `part_involves_fragment_ref`
2631    /// keys on `Value::FragmentRef` alone, and `brink-codegen-inkb::content::
2632    /// emit_slot_expr`'s `BeginFragment`…`EndFragment` composition pattern
2633    /// wraps *every* template slot whose expr is a function call
2634    /// (`lir::Expr::is_function_call()`), in ordinary display position, in
2635    /// both dialects — not just a `block` receiver. This pins that broader,
2636    /// actual scope directly: a line whose only content is a call like
2637    /// `{ f() }`, where `f` emits no side-effect text and returns an empty
2638    /// value, is suppressed by the exact same mechanism as the block-capture
2639    /// case above, with no `block`-capture machinery involved at all.
2640    #[test]
2641    fn resolve_lines_suppresses_a_blank_line_from_an_empty_display_position_call_composition() {
2642        // line 0: "Before.", line 1: `{f()}` (ordinary call composition —
2643        // NOT a `block`-capture receiver), line 2: "After."
2644        let (program, line_tables) = program_with_line_table(vec![
2645            plain_entry("Before."),
2646            one_slot_template_entry(),
2647            plain_entry("After."),
2648        ]);
2649        // Models `emit_slot_expr`'s composition pattern for `{ f() }`
2650        // where `f` produced no side-effect output and its return value
2651        // stringified to empty — a real, present `Fragment` with no parts,
2652        // exactly as a `block` capture's empty fragment looks structurally.
2653        let fragments = Fragments::from(vec![Fragment {
2654            parts: vec![],
2655            tags: vec![],
2656        }]);
2657
2658        let parts = vec![
2659            line_ref(0, vec![], brink_format::LineFlags::from_plain("Before.")),
2660            OutputPart::Newline,
2661            line_ref(
2662                1,
2663                vec![Value::FragmentRef(0)],
2664                brink_format::LineFlags::empty(),
2665            ),
2666            OutputPart::Newline,
2667            line_ref(2, vec![], brink_format::LineFlags::from_plain("After.")),
2668        ];
2669
2670        // Element-attachment data (issue #2108) is dropped here — these
2671        // pre-existing fixtures don't exercise attach conventions.
2672        let lines: Vec<(String, Vec<String>)> =
2673            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2674                .into_iter()
2675                .map(|(text, tags, _element, _source)| (text, tags))
2676                .collect();
2677        assert_eq!(
2678            lines,
2679            vec![
2680                ("Before.".to_string(), Vec::<String>::new()),
2681                ("After.".to_string(), Vec::<String>::new()),
2682            ],
2683            "an empty display-position call-composition FragmentRef must be \
2684             suppressed identically to a block capture — this is the \
2685             broader scope the discriminator actually covers, not just \
2686             #1839's block-capture receiver: {lines:?}"
2687        );
2688    }
2689
2690    /// Scope boundary: this fix is specifically about `content`/Fragment
2691    /// captures, not "any interpolation that happens to render empty". A
2692    /// `Slot` bound to a plain, non-`FragmentRef` value that resolves empty
2693    /// keeps its pre-existing blank beat — unchanged, matching the
2694    /// deliberately-preserved `inline-markup-point-marker` fixture (a
2695    /// self-closing markup span with no children, issue #1716).
2696    #[test]
2697    fn resolve_lines_does_not_suppress_a_blank_line_from_a_non_fragment_empty_slot() {
2698        let (program, line_tables) = program_with_line_table(vec![
2699            plain_entry("VENDOR"),
2700            one_slot_template_entry(),
2701            plain_entry("(hushed)"),
2702        ]);
2703        let fragments = Fragments::default();
2704
2705        let parts = vec![
2706            line_ref(0, vec![], brink_format::LineFlags::from_plain("VENDOR")),
2707            OutputPart::Newline,
2708            line_ref(1, vec![Value::Null], brink_format::LineFlags::empty()),
2709            OutputPart::Newline,
2710            line_ref(2, vec![], brink_format::LineFlags::from_plain("(hushed)")),
2711        ];
2712
2713        // Element-attachment data (issue #2108) is dropped here — these
2714        // pre-existing fixtures don't exercise attach conventions.
2715        let lines: Vec<(String, Vec<String>)> =
2716            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2717                .into_iter()
2718                .map(|(text, tags, _element, _source)| (text, tags))
2719                .collect();
2720        assert_eq!(
2721            lines,
2722            vec![
2723                ("VENDOR".to_string(), Vec::<String>::new()),
2724                (String::new(), Vec::<String>::new()),
2725                ("(hushed)".to_string(), Vec::<String>::new()),
2726            ],
2727            "a non-Fragment empty slot must keep rendering its blank line: {lines:?}"
2728        );
2729    }
2730
2731    /// Streaming-API regression (the actual bug shape): `take_first_line`
2732    /// must skip the suppressed blank line silently — never handing it back
2733    /// as its own `Line::Text` — while still returning "VENDOR" and
2734    /// "(hushed)" as two separate completed lines, in order, with the
2735    /// cursor correctly advanced (no stall on the suppressed segment).
2736    #[test]
2737    fn take_first_line_skips_a_suppressed_line_and_returns_the_next_real_line() {
2738        let (program, line_tables) = program_with_line_table(vec![
2739            plain_entry("VENDOR"),
2740            one_slot_template_entry(),
2741            plain_entry("(hushed)"),
2742        ]);
2743
2744        let mut buf = OutputBuffer::new();
2745        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2746        buf.push_newline();
2747        buf.begin_fragment();
2748        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2749        buf.push_line_ref(
2750            0,
2751            1,
2752            vec![Value::FragmentRef(frag_idx)],
2753            brink_format::LineFlags::empty(),
2754        );
2755        buf.push_newline();
2756        buf.push_line_ref(
2757            0,
2758            2,
2759            vec![],
2760            brink_format::LineFlags::from_plain("(hushed)"),
2761        );
2762        buf.push_newline();
2763
2764        let mut got = Vec::new();
2765        // Bounded loop (VM-test hygiene): at most 3 real lines are possible
2766        // here, so 5 iterations is generous headroom against a stall.
2767        for _ in 0..5 {
2768            match buf.take_first_line(&program, &line_tables, None) {
2769                Some((text, _, _, _)) => got.push(text),
2770                None => break,
2771            }
2772        }
2773
2774        assert_eq!(
2775            got,
2776            vec!["VENDOR\n".to_string(), "(hushed)\n".to_string()],
2777            "the empty content capture must not surface as its own \
2778             (blank) streamed line: {got:?}"
2779        );
2780    }
2781
2782    /// Issue #2147 (gap 1 of #2091's follow-through review): `end_capture`
2783    /// -> `resolve_parts` is the string-capture path — the `EndStringEval`
2784    /// path an unrecognized choice display or `~ temp x = "..."` string-eval
2785    /// rides — and PR #2140 only fixed the line-oriented
2786    /// `resolve_lines`/`take_first_line` path. Same VENDOR / `{body}` /
2787    /// (hushed) shape as `resolve_lines_suppresses_a_blank_line_from_an_
2788    /// empty_content_capture`, but captured as a single string via
2789    /// `begin_capture`/`end_capture` instead of resolved line-by-line.
2790    #[test]
2791    fn end_capture_suppresses_a_blank_line_from_an_empty_content_capture() {
2792        let (program, line_tables) = program_with_line_table(vec![
2793            plain_entry("VENDOR"),
2794            one_slot_template_entry(),
2795            plain_entry("(hushed)"),
2796        ]);
2797
2798        let mut buf = OutputBuffer::new();
2799        // A real, present (empty) Fragment — same shape #1839's block
2800        // capture and #2140's display-position call composition produce.
2801        buf.begin_fragment();
2802        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2803
2804        buf.begin_capture();
2805        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2806        buf.push_newline();
2807        buf.push_line_ref(
2808            0,
2809            1,
2810            vec![Value::FragmentRef(frag_idx)],
2811            brink_format::LineFlags::empty(),
2812        );
2813        buf.push_newline();
2814        buf.push_line_ref(
2815            0,
2816            2,
2817            vec![],
2818            brink_format::LineFlags::from_plain("(hushed)"),
2819        );
2820
2821        let text = buf
2822            .end_capture(&program, &line_tables, None)
2823            .expect("checkpoint was just pushed");
2824        assert_eq!(
2825            text, "VENDOR\n(hushed)",
2826            "an empty content/Fragment capture inside a captured string \
2827             must not leave a stray blank line — must match resolve_lines' \
2828             suppression: {text:?}"
2829        );
2830    }
2831
2832    /// Scope boundary, mirrored from
2833    /// `resolve_lines_does_not_suppress_a_blank_line_from_a_non_fragment_
2834    /// empty_slot`: a `Slot` bound to a plain, non-`FragmentRef` value that
2835    /// resolves empty keeps its pre-existing blank line inside a captured
2836    /// string too — this fix is about `content`/Fragment captures
2837    /// specifically, not "any interpolation that happens to render empty".
2838    #[test]
2839    fn end_capture_does_not_suppress_a_blank_line_from_a_non_fragment_empty_slot() {
2840        let (program, line_tables) = program_with_line_table(vec![
2841            plain_entry("VENDOR"),
2842            one_slot_template_entry(),
2843            plain_entry("(hushed)"),
2844        ]);
2845
2846        let mut buf = OutputBuffer::new();
2847        buf.begin_capture();
2848        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2849        buf.push_newline();
2850        buf.push_line_ref(0, 1, vec![Value::Null], brink_format::LineFlags::empty());
2851        buf.push_newline();
2852        buf.push_line_ref(
2853            0,
2854            2,
2855            vec![],
2856            brink_format::LineFlags::from_plain("(hushed)"),
2857        );
2858
2859        let text = buf
2860            .end_capture(&program, &line_tables, None)
2861            .expect("checkpoint was just pushed");
2862        assert_eq!(
2863            text, "VENDOR\n\n(hushed)",
2864            "a non-Fragment empty slot must keep its blank line inside a \
2865             captured string: {text:?}"
2866        );
2867    }
2868
2869    /// Review finding on issue #2147's PR: `resolve_lines_annotated`
2870    /// deliberately suppresses its own final, unterminated entry (the
2871    /// `EXCEPTION (issue #2091)` block above it) — dropping the trailing
2872    /// newline along with it — while `resolve_parts`'s suppression only
2873    /// fired on an `OutputPart::Newline`. A captured string whose *last*
2874    /// segment (no trailing `Newline` part) is empty and Fragment-derived
2875    /// must drop that trailing newline too, matching `resolve_lines`.
2876    #[test]
2877    fn end_capture_drops_trailing_newline_before_an_unterminated_empty_fragment() {
2878        let (program, line_tables) =
2879            program_with_line_table(vec![plain_entry("a"), one_slot_template_entry()]);
2880
2881        let mut buf = OutputBuffer::new();
2882        // A real, present (empty) Fragment — same shape as the other
2883        // tests in this module.
2884        buf.begin_fragment();
2885        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2886
2887        buf.begin_capture();
2888        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("a"));
2889        buf.push_newline();
2890        // No trailing newline after this — the capture ends mid-line, same
2891        // as an unread transcript tail ending on an empty Fragment
2892        // interpolation.
2893        buf.push_line_ref(
2894            0,
2895            1,
2896            vec![Value::FragmentRef(frag_idx)],
2897            brink_format::LineFlags::empty(),
2898        );
2899
2900        let text = buf
2901            .end_capture(&program, &line_tables, None)
2902            .expect("checkpoint was just pushed");
2903        assert_eq!(
2904            text, "a",
2905            "an unterminated trailing empty Fragment interpolation must \
2906             drop its introducing newline too, matching resolve_lines' \
2907             final-entry suppression: {text:?}"
2908        );
2909    }
2910
2911    /// Review finding on issue #2147's PR: `resolve_parts`'s new
2912    /// suppression is reached not only from `end_capture`'s string-capture
2913    /// path but also from [`OutputBuffer::resolve_fragment`] — including
2914    /// when resolving a *nested* fragment's own interior, when that inner
2915    /// fragment's captured region spans more than one line and one of
2916    /// those interior lines is contributed purely by a further-nested,
2917    /// rendered-empty fragment. Pin that this interior suppression fires
2918    /// identically to the top-level `resolve_lines`/`end_capture` case —
2919    /// this is the "nested/multi-line fragment interior" effect the
2920    /// doc comment on `resolve_parts` discloses.
2921    #[test]
2922    fn resolve_fragment_suppresses_a_blank_line_from_a_nested_empty_fragment_interior() {
2923        let (program, line_tables) = program_with_line_table(vec![
2924            plain_entry("VENDOR"),
2925            one_slot_template_entry(),
2926            plain_entry("(hushed)"),
2927        ]);
2928
2929        let mut buf = OutputBuffer::new();
2930
2931        // The inner, empty Fragment (e.g. a block-capture receiver that
2932        // captured nothing).
2933        buf.begin_fragment();
2934        let inner_idx = buf.end_fragment().expect("checkpoint was just pushed");
2935
2936        // The outer Fragment: three lines, with the middle one contributed
2937        // purely by the (empty) inner Fragment — i.e. a multi-line
2938        // fragment whose own interior has a suppressible blank line.
2939        buf.begin_fragment();
2940        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2941        buf.push_newline();
2942        buf.push_line_ref(
2943            0,
2944            1,
2945            vec![Value::FragmentRef(inner_idx)],
2946            brink_format::LineFlags::empty(),
2947        );
2948        buf.push_newline();
2949        buf.push_line_ref(
2950            0,
2951            2,
2952            vec![],
2953            brink_format::LineFlags::from_plain("(hushed)"),
2954        );
2955        let outer_idx = buf.end_fragment().expect("checkpoint was just pushed");
2956
2957        let text = buf.resolve_fragment(outer_idx, &program, &line_tables, None);
2958        assert_eq!(
2959            text, "VENDOR\n(hushed)",
2960            "a multi-line fragment's own interior must suppress a blank \
2961             line from a nested, rendered-empty fragment the same way the \
2962             top-level resolve_lines/end_capture paths do: {text:?}"
2963        );
2964    }
2965
2966    /// Review finding on issue #2108's PR: unlike [`OutputBuffer::
2967    /// take_first_line`], [`OutputBuffer::flush_lines`] seeded
2968    /// `pending_element` from `self.pending_element` but never wrote the
2969    /// end-of-slice state back — so an `ElementAttachEnd` consumed by a
2970    /// `flush_lines` call was lost and the attach data stayed live forever
2971    /// on whatever the buffer resolved next. Drain an attach run's first
2972    /// line through `take_first_line` (the call that seeds
2973    /// `pending_element` in the first place) and its remainder — including
2974    /// the closing `ElementAttachEnd` — through `flush_lines` in one shot,
2975    /// then prove a line pushed afterward does NOT inherit the closed
2976    /// run's data.
2977    #[test]
2978    fn flush_lines_writes_back_pending_element_past_the_closed_run() {
2979        let p = test_dummy_program();
2980        let mut buf = OutputBuffer::new();
2981
2982        buf.push_element_attach("speaker".to_string(), "VENDOR".to_string());
2983        buf.push_text("Line one.");
2984        buf.push_newline();
2985        buf.push_text("Line two.");
2986        buf.push_newline();
2987        buf.push_element_attach_end();
2988
2989        let (first_text, _, first_element, _) = buf
2990            .take_first_line(&p, &[], None)
2991            .expect("first line of the attach run");
2992        assert_eq!(first_text, "Line one.\n");
2993        assert_eq!(
2994            first_element.get("speaker").map(String::as_str),
2995            Some("VENDOR")
2996        );
2997
2998        let rest = buf.flush_lines(&p, &[], None);
2999        let line_two = rest
3000            .iter()
3001            .find(|(text, ..)| text == "Line two.")
3002            .expect("Line two. present in the flush");
3003        assert_eq!(
3004            line_two.2.get("speaker").map(String::as_str),
3005            Some("VENDOR"),
3006            "the last line of the run itself must still carry the attach data: {rest:?}"
3007        );
3008
3009        // Pushed after the run closed — must not inherit "speaker": "VENDOR".
3010        buf.push_text("Unattached.");
3011        buf.push_newline();
3012        let (after_text, _, after_element, _) = buf
3013            .take_first_line(&p, &[], None)
3014            .expect("line after the closed run");
3015        assert_eq!(after_text, "Unattached.\n");
3016        assert!(
3017            after_element.is_empty(),
3018            "flush_lines must write pending_element back to empty once it \
3019             consumes the run-closing ElementAttachEnd: {after_element:?}"
3020        );
3021    }
3022
3023    /// Review finding on issue #2108's PR: [`OutputBuffer::reset_cursor`]
3024    /// rewound `self.cursor` but left `pending_element` populated. At index
3025    /// 0 no attach run has accumulated yet, so a locale hot-swap re-render
3026    /// (the public use of `reset_cursor`) leaked the previous drain pass's
3027    /// element data onto the re-drained leading line.
3028    ///
3029    /// Transcript: `[narration, NL, ElementAttach(speaker=VENDOR), dialogue,
3030    /// NL]` — the exact probe from the finding. The narration line reports
3031    /// `{}` on the first pass (the attach hasn't happened yet); after
3032    /// draining the whole buffer once and calling `reset_cursor`, the
3033    /// re-drained narration line must report `{}` again too, not the
3034    /// dialogue run's `speaker` leaking backward from the previous pass.
3035    #[test]
3036    fn reset_cursor_clears_pending_element() {
3037        let p = test_dummy_program();
3038        let mut buf = OutputBuffer::new();
3039
3040        buf.push_text("Intro.");
3041        buf.push_newline();
3042        buf.push_element_attach("speaker".to_string(), "VENDOR".to_string());
3043        buf.push_text("Dialogue.");
3044        buf.push_newline();
3045
3046        let (first_text, _, first_element, _) =
3047            buf.take_first_line(&p, &[], None).expect("narration line");
3048        assert_eq!(first_text, "Intro.\n");
3049        assert!(first_element.is_empty(), "{first_element:?}");
3050
3051        let (second_text, _, second_element, _) =
3052            buf.take_first_line(&p, &[], None).expect("dialogue line");
3053        assert_eq!(second_text, "Dialogue.\n");
3054        assert_eq!(
3055            second_element.get("speaker").map(String::as_str),
3056            Some("VENDOR")
3057        );
3058
3059        buf.reset_cursor();
3060        let (text_after_reset, _, element_after_reset, _) = buf
3061            .take_first_line(&p, &[], None)
3062            .expect("re-drained narration line after reset_cursor");
3063        assert_eq!(text_after_reset, "Intro.\n");
3064        assert!(
3065            element_after_reset.is_empty(),
3066            "reset_cursor must clear pending_element — no attach run has \
3067             accumulated yet at index 0, so the re-drained leading line \
3068             must not inherit the previous pass's speaker: \
3069             {element_after_reset:?}"
3070        );
3071    }
3072
3073    /// Issue #3556: `trim_function_end` must not walk behind the read
3074    /// cursor.
3075    ///
3076    /// A function whose body spans a yield point — it printed a line, the
3077    /// consumer took it, and only then did the function return — has a
3078    /// `start` recorded before parts that have since been delivered. C# has
3079    /// no such case because its output stream really is emptied at each
3080    /// yield (`ResetOutput`); brink keeps the whole transcript with a cursor
3081    /// over it, so the cursor is where that reset happened.
3082    ///
3083    /// Without the floor the transcript ends up shorter than the cursor and
3084    /// the next reader of `transcript[cursor..]` panics — which is how this
3085    /// surfaced, out of `brink-gen`'s `both_roads_agree`.
3086    #[test]
3087    fn trim_function_end_stops_at_the_read_cursor() {
3088        let mut buf = OutputBuffer::new();
3089        // The function's output, all of it after `start = 0`.
3090        let start = buf.target_len();
3091        buf.push_text("1");
3092        buf.push_newline();
3093        // An empty list renders as whitespace, so #3536 makes it trimmable
3094        // — and it is content, so it commits the newline behind it.
3095        buf.push_value_ref(Value::List(alloc::sync::Arc::new(
3096            brink_format::ListValue {
3097                items: Vec::new(),
3098                origins: Vec::new(),
3099            },
3100        )));
3101        buf.push_newline();
3102
3103        // The consumer takes the completed line; the cursor advances past
3104        // the two parts that produced it.
3105        assert_eq!(
3106            buf.test_take_first_line().map(|(t, _)| t),
3107            Some("1\n".to_owned())
3108        );
3109        let cursor = buf.cursor;
3110        assert_eq!(cursor, 2, "the delivered line is the first two parts");
3111
3112        buf.trim_function_end(start);
3113
3114        assert!(
3115            buf.transcript.len() >= cursor,
3116            "the trim walked behind the cursor: transcript is {} parts, \
3117             cursor is at {cursor}",
3118            buf.transcript.len()
3119        );
3120        // What it *should* have trimmed: everything the consumer has not
3121        // seen, since all of it renders as whitespace.
3122        assert_eq!(buf.transcript.len(), cursor, "the unread tail is trimmed");
3123        // And the invariant every reader depends on now holds.
3124        assert!(buf.test_take_first_line().is_none());
3125    }
3126}