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, ToString};
7use alloc::vec;
8use alloc::vec::Vec;
9
10use brink_format::{
11    LineContent, LineEntry, LinePart, PluralCategory, PluralResolver, SelectKey, Value,
12};
13
14use crate::program::Program;
15use crate::value_ops;
16
17mod consume;
18mod fragment;
19
20pub use fragment::Fragment;
21
22/// A part of accumulated output.
23///
24/// Output parts are structural references that resolve at read time against
25/// the current line tables and plural resolver. This enables locale-hot-swap:
26/// the same transcript can be re-rendered in different languages without
27/// re-executing the story.
28///
29/// `PartialEq` (issue #746): structural equality over the part's own fields
30/// — used by the `.brkt` transcript round-trip law
31/// (`brink-runtime/tests/law_transcript_roundtrip.rs`) to assert decoded
32/// parts equal the originals. Every field type already implements it
33/// (`Value`'s hand-written impl, `LineFlags`'s derive).
34#[derive(Debug, Clone, PartialEq)]
35pub enum OutputPart {
36    /// Eagerly-resolved text. Not produced by the VM in production —
37    /// used in tests and available for external transcript construction.
38    Text(String),
39    /// Deferred line reference — resolved at read time against the
40    /// current line tables and plural resolver.
41    LineRef {
42        container_idx: u32,
43        line_idx: u16,
44        slots: Vec<Value>,
45        flags: brink_format::LineFlags,
46    },
47    /// Deferred value — stringified at read time.
48    ValueRef(Value),
49    Newline,
50    /// Word break — renders as a single space between content parts.
51    Spring,
52    Glue,
53    /// Marks the start of a captured region (string eval, tag, or function call).
54    Checkpoint,
55    /// A tag associated with the current line of output.
56    Tag(String),
57    /// One field of an `attach = StructName` convention handler's return
58    /// value, merged into the run currently open (issue #2108,
59    /// `docs/decision-log.md` 2026-08-03 "The element output model:
60    /// attachment is block-level metadata, delivery is per-line"). Embedded
61    /// in the SAME append-only stream as `Tag`, rather than mutated on
62    /// `Flow` directly, for the identical reason tags are: the output
63    /// buffer defers a `Newline`'s commitment until later content proves no
64    /// `Glue` reaches back over it (`OutputBuffer::has_completed_line`'s own
65    /// doc), so by the time a line is finally drained the VM may already
66    /// have stepped past several MORE opcodes (including a later run's own
67    /// `ElementAttach`/`ElementAttachEnd`). Reading a live, continuously-
68    /// mutated `Flow` field at drain time would misattribute a LATER run's
69    /// data to an EARLIER, still-buffered line — embedding the merge as its
70    /// own transcript entry, at the exact point it actually happened,
71    /// avoids that entirely: [`resolve_lines_annotated`] rebuilds the
72    /// correct per-line snapshot by walking the stream in order, the same
73    /// way it already does for `Tag`.
74    ///
75    /// Unlike `Tag` (which resets every line), this ACCUMULATES across
76    /// multiple lines until a matching [`Self::ElementAttachEnd`] closes the
77    /// run — ruling item 4/5: "the run IS the block" and "every line in it
78    /// carries a copy."
79    ///
80    /// **Not part of the persisted `.brkt` format** (`crate::transcript`'s
81    /// `is_persisted`) — like [`Self::Checkpoint`], this is in-memory-only
82    /// bookkeeping. Issue #2108 is explicitly scoped to "the in-memory half"
83    /// (see its own tracked follow-up on save/resume); a transcript replayed
84    /// from a `.brkt` file loses element attachment, matching that scope.
85    ElementAttach(String, String),
86    /// Closes the run an [`Self::ElementAttach`] opened, clearing the
87    /// accumulated data so content after this point is never misattributed
88    /// to a run it wasn't part of. See [`Self::ElementAttach`]'s doc for why
89    /// this lives in the transcript stream rather than on `Flow`, and for
90    /// its non-persisted status.
91    ElementAttachEnd,
92}
93
94impl OutputPart {
95    /// Resolve this output part to its text representation.
96    ///
97    /// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
98    /// using the provided program, line tables, and plural resolver.
99    /// Structural parts (`Newline`, `Spring`, `Glue`, `Checkpoint`, `Tag`)
100    /// resolve to empty string — they are handled by the resolution pipeline.
101    pub fn resolve(
102        &self,
103        program: &Program,
104        line_tables: &[Vec<LineEntry>],
105        resolver: Option<&dyn PluralResolver>,
106    ) -> String {
107        resolve_part(self, program, line_tables, resolver, &[])
108    }
109
110    /// Returns true if this part represents non-whitespace text content.
111    fn is_content(&self) -> bool {
112        match self {
113            Self::Text(s) => !s.trim().is_empty(),
114            Self::LineRef { flags, .. } => {
115                !flags.contains(brink_format::LineFlags::ALL_WS)
116                    && !flags.contains(brink_format::LineFlags::EMPTY)
117            }
118            // B4 (`docs/stdlib-spec.md` §1.6b): a final-`None` value at the
119            // display boundary resolves to the empty string (see
120            // `value_ops::stringify_display`) — it must not count as
121            // content for leading-newline/glue suppression, matching how
122            // an eagerly-dropped `Value::Null` never reaches the
123            // transcript at all (`push_value_ref`, below). Unlike `Null`,
124            // a `None` value IS retained in the transcript (traceability
125            // is a §1.6b rider) — only its content-ness for whitespace
126            // bookkeeping is suppressed here.
127            Self::ValueRef(Value::OptionVal(None)) => false,
128            Self::ValueRef(_) => true,
129            _ => false,
130        }
131    }
132}
133
134/// Resolve a single output part to its text representation.
135///
136/// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
137/// using the provided program, line tables, and plural resolver.
138fn resolve_part(
139    part: &OutputPart,
140    program: &Program,
141    line_tables: &[Vec<LineEntry>],
142    resolver: Option<&dyn PluralResolver>,
143    fragments: &[Fragment],
144) -> String {
145    match part {
146        OutputPart::Text(s) => s.clone(),
147        OutputPart::LineRef {
148            container_idx,
149            line_idx,
150            slots,
151            ..
152        } => resolve_line_ref(
153            program,
154            line_tables,
155            *container_idx,
156            *line_idx,
157            slots,
158            resolver,
159            fragments,
160        ),
161        OutputPart::ValueRef(Value::FragmentRef(idx)) => {
162            // Resolve the fragment's parts against current line tables.
163            let idx = *idx as usize;
164            if let Some(frag) = fragments.get(idx) {
165                resolve_parts(&frag.parts, program, line_tables, resolver, fragments)
166            } else {
167                String::new()
168            }
169        }
170        // B4 (`docs/stdlib-spec.md` §1.6b): the display boundary — a
171        // final-`None` value renders as nothing, not `"none"`. See
172        // `value_ops::stringify_display`'s doc comment for the full ruling.
173        OutputPart::ValueRef(val) => value_ops::stringify_display(val, program),
174        OutputPart::Newline
175        | OutputPart::Spring
176        | OutputPart::Glue
177        | OutputPart::Checkpoint
178        | OutputPart::Tag(_)
179        | OutputPart::ElementAttach(..)
180        | OutputPart::ElementAttachEnd => String::new(),
181    }
182}
183
184/// Resolve a `LineRef` to its text content.
185fn resolve_line_ref(
186    program: &Program,
187    line_tables: &[Vec<LineEntry>],
188    container_idx: u32,
189    line_idx: u16,
190    slots: &[Value],
191    resolver: Option<&dyn PluralResolver>,
192    fragments: &[Fragment],
193) -> String {
194    let scope_idx = program.scope_table_idx(container_idx) as usize;
195    let lines = &line_tables[scope_idx];
196    let Some(entry) = lines.get(line_idx as usize) else {
197        return String::new();
198    };
199
200    match &entry.content {
201        LineContent::Plain(s) => s.clone(),
202        LineContent::Template(parts) => {
203            resolve_line_parts(parts, program, line_tables, slots, resolver, fragments)
204        }
205    }
206}
207
208/// Resolve a sequence of `LinePart`s (a `LineContent::Template`'s own, or a
209/// [`LinePart::Span`]'s `children`) to flat text.
210///
211/// A span is presentational (§4.3) and the runtime's current public API
212/// (`Line::Text.text`) is flat text with no structured span surface yet
213/// (`docs/prose-dialect-spec.md` §7/§9.1: the `Step`/`Part` redesign that
214/// would carry `Part::Span` structure through to a consumer is still ⏳) —
215/// so a span resolves here to its children's concatenated text, tag name
216/// and attrs stripped, recursing through this same function. That is
217/// additive groundwork for the future structured surface, not a
218/// replacement of it: §4.4 explicitly wants "structural parts over
219/// byte-range offsets" once that surface lands.
220fn resolve_line_parts(
221    parts: &[LinePart],
222    program: &Program,
223    line_tables: &[Vec<LineEntry>],
224    slots: &[Value],
225    resolver: Option<&dyn PluralResolver>,
226    fragments: &[Fragment],
227) -> String {
228    let mut result = String::new();
229    for part in parts {
230        let owned;
231        let fragment: &str = match part {
232            LinePart::Literal(s) => s.as_str(),
233            LinePart::Slot(n) => {
234                owned = slots
235                    .get(*n as usize)
236                    .map(|v| match v {
237                        Value::FragmentRef(idx) => {
238                            let idx = *idx as usize;
239                            fragments.get(idx).map_or_else(String::new, |frag| {
240                                resolve_parts(
241                                    &frag.parts,
242                                    program,
243                                    line_tables,
244                                    resolver,
245                                    fragments,
246                                )
247                            })
248                        }
249                        // B4 (`docs/stdlib-spec.md` §1.6b) — same
250                        // display-boundary forgiveness as the
251                        // `ValueRef` arm above; the surrounding
252                        // whitespace-collapse logic below already
253                        // treats an empty slot fragment correctly.
254                        other => value_ops::stringify_display(other, program),
255                    })
256                    .unwrap_or_default();
257                owned.as_str()
258            }
259            LinePart::Select {
260                slot,
261                variants,
262                default,
263            } => {
264                owned = resolve_select(*slot, variants, default, slots, resolver).to_string();
265                owned.as_str()
266            }
267            LinePart::Span { children, .. } => {
268                owned =
269                    resolve_line_parts(children, program, line_tables, slots, resolver, fragments);
270                owned.as_str()
271            }
272        };
273        // Skip empty fragments (null/empty slots) and collapse
274        // whitespace at join points when empty slots produce
275        // adjacent spaces or leading whitespace.
276        if fragment.is_empty() {
277            continue;
278        }
279        if (result.is_empty() || result.ends_with(' ')) && fragment.starts_with(' ') {
280            result.push_str(fragment.trim_start());
281        } else {
282            result.push_str(fragment);
283        }
284    }
285    result
286}
287
288/// Resolve a Select part against its slot value.
289///
290/// Cascade: Exact → Keyword → Cardinal/Ordinal → default.
291fn resolve_select<'a>(
292    slot: u8,
293    variants: &'a [(SelectKey, String)],
294    default: &'a str,
295    slots: &[Value],
296    resolver: Option<&dyn PluralResolver>,
297) -> &'a str {
298    let Some(val) = slots.get(slot as usize) else {
299        return default;
300    };
301
302    #[expect(clippy::cast_possible_truncation)]
303    let n: Option<i64> = match val {
304        Value::Int(i) => Some(i64::from(*i)),
305        Value::Float(f) => Some(*f as i64),
306        _ => None,
307    };
308
309    // Exact match.
310    if let Some(n) = n {
311        #[expect(clippy::cast_possible_truncation)]
312        let n32 = n as i32;
313        for (key, text) in variants {
314            if let SelectKey::Exact(e) = key
315                && *e == n32
316            {
317                return text;
318            }
319        }
320    }
321
322    // Keyword match.
323    if let Value::String(s) = val {
324        for (key, text) in variants {
325            if let SelectKey::Keyword(k) = key
326                && k == s.as_ref()
327            {
328                return text;
329            }
330        }
331    }
332
333    // Plural resolution.
334    if let (Some(n), Some(r)) = (n, resolver) {
335        let cardinal: PluralCategory = r.cardinal(n, None);
336        for (key, text) in variants {
337            if let SelectKey::Cardinal(cat) = key
338                && *cat == cardinal
339            {
340                return text;
341            }
342        }
343        let ordinal: PluralCategory = r.ordinal(n);
344        for (key, text) in variants {
345            if let SelectKey::Ordinal(cat) = key
346                && *cat == ordinal
347            {
348                return text;
349            }
350        }
351    }
352
353    default
354}
355
356/// Accumulates output text with glue resolution.
357///
358/// The buffer is split into two storage areas:
359/// - **transcript**: append-only log of all output parts. Never drained.
360///   A read cursor advances on `take_first_line`/`flush_lines`.
361/// - **capture**: transient scratch space for string eval, tag collection,
362///   and function return value capture. Drained by `end_capture`.
363#[derive(Debug, Clone)]
364pub(crate) struct OutputBuffer {
365    /// Append-only output log. Parts are never removed.
366    pub(crate) transcript: Vec<OutputPart>,
367    /// Read cursor into transcript. Advances on take/flush.
368    pub(crate) cursor: usize,
369    /// Transient capture scratch space.
370    capture: Vec<OutputPart>,
371    /// Nesting depth of active captures. When > 0, pushes route to `capture`.
372    capture_depth: usize,
373    /// Finalized fragments — structural output parts for locale re-rendering.
374    fragments: Vec<Fragment>,
375    /// Current fragment being captured.
376    fragment_capture: Vec<OutputPart>,
377    /// Fragment capture nesting depth. When > 0, pushes route to `fragment_capture`.
378    fragment_depth: usize,
379    /// Tags accumulated during each nested fragment capture level.
380    fragment_pending_tags: Vec<Vec<String>>,
381    /// Element-attachment state (issue #2108) carried forward across
382    /// separate [`Self::take_first_line`] calls — the streaming, one-line-
383    /// at-a-time API resolves only the slice through each line's own
384    /// completing `Newline`, so a run spanning MULTIPLE lines (ruling item
385    /// 5: "every line in it carries a copy") would otherwise lose the data
386    /// after its first line, once the cursor has advanced past the
387    /// `ElementAttach` parts that live before it. Seeded into
388    /// `resolve_lines_annotated` at the start of each call and updated from
389    /// its trailing state afterward — see `take_first_line`'s own doc.
390    /// [`Self::flush_lines`] needs no equivalent: it resolves the entire
391    /// remaining tail in one call, so the accumulation stays correct
392    /// without carrying anything between separate calls.
393    pending_element: BTreeMap<String, String>,
394}
395
396impl OutputBuffer {
397    pub fn new() -> Self {
398        Self {
399            transcript: Vec::new(),
400            cursor: 0,
401            capture: Vec::new(),
402            capture_depth: 0,
403            fragments: Vec::new(),
404            fragment_capture: Vec::new(),
405            fragment_depth: 0,
406            fragment_pending_tags: Vec::new(),
407            pending_element: BTreeMap::new(),
408        }
409    }
410
411    /// Returns the active push target.
412    /// Priority: capture (eagerly resolves) > fragment (structural) > transcript.
413    fn target(&mut self) -> &mut Vec<OutputPart> {
414        if self.capture_depth > 0 {
415            &mut self.capture
416        } else if self.fragment_depth > 0 {
417            &mut self.fragment_capture
418        } else {
419            &mut self.transcript
420        }
421    }
422
423    /// Length of the active push target. Used to record function output
424    /// start points for trailing whitespace trim on return.
425    pub(crate) fn target_len(&self) -> usize {
426        if self.capture_depth > 0 {
427            self.capture.len()
428        } else if self.fragment_depth > 0 {
429            self.fragment_capture.len()
430        } else {
431            self.transcript.len()
432        }
433    }
434
435    /// Trim trailing whitespace from the active output target, walking
436    /// backward to `start`. Matches the C# runtime's
437    /// `TrimWhitespaceFromFunctionEnd`: on function return, remove
438    /// trailing `Newline`, `Spring`, and whitespace-only text so that
439    /// function output doesn't inject unwanted line breaks.
440    pub(crate) fn trim_function_end(&mut self, start: usize) {
441        let target = self.target();
442        while target.len() > start {
443            match target.last() {
444                Some(OutputPart::Newline | OutputPart::Spring) => {
445                    target.pop();
446                }
447                Some(OutputPart::Text(s)) if s.trim().is_empty() => {
448                    target.pop();
449                }
450                Some(OutputPart::LineRef { flags, .. })
451                    if flags.contains(brink_format::LineFlags::ALL_WS) =>
452                {
453                    target.pop();
454                }
455                _ => break,
456            }
457        }
458    }
459
460    /// No longer called by the VM — candidate for removal.
461    #[cfg(test)]
462    pub fn push_text(&mut self, text: &str) {
463        if text.is_empty() {
464            return;
465        }
466        // Suppress whitespace-only text when there's no content yet,
467        // matching the C# ink runtime's output stream filtering.
468        // This handles leading spaces after choice selection (`"^ "`).
469        if !self.has_content() && text.trim().is_empty() {
470            return;
471        }
472        // Collapse adjacent whitespace at text boundaries: if the
473        // previous text part ends with whitespace and this text starts
474        // with whitespace, trim the leading whitespace from this text.
475        let text = if text.starts_with(char::is_whitespace) && self.ends_in_whitespace() {
476            text.trim_start()
477        } else {
478            text
479        };
480        if !text.is_empty() {
481            self.target().push(OutputPart::Text(text.to_owned()));
482        }
483    }
484
485    pub fn push_newline(&mut self) {
486        // Suppress leading newlines (no content yet) and duplicate newlines,
487        // matching the C# ink runtime's output stream filtering.
488        //
489        // Inside a capture, use scope-local has_content().  Outside, check
490        // the unread transcript for content **or Spring** — Spring is brink's
491        // equivalent of the C# `"^ "` (space) that inklecate emits in choice
492        // targets.  In C#, that space is a StringValue which makes
493        // `outputStreamContainsContent` true, allowing the subsequent newline
494        // through.  Without counting Spring, post-choice newlines are lost.
495        let has_content = if self.capture_depth > 0 || self.fragment_depth > 0 {
496            self.has_content()
497        } else {
498            self.unread_has_content_or_spring()
499        };
500        if !has_content || self.ends_in_newline() {
501            return;
502        }
503        self.target().push(OutputPart::Newline);
504    }
505
506    /// Returns true if the active target contains any text content.
507    /// When inside a capture, scans the capture vec (stopping at checkpoint).
508    /// When inside a fragment (and no capture is active — same priority
509    /// `target()` uses), scans `fragment_capture` the identical way, stopping
510    /// at *its own* checkpoint (issue #1839: a fragment capturing more than
511    /// one recognized line needs this to see the lines it has already
512    /// captured at THIS nesting level, not the outer transcript, which a
513    /// multi-statement block capture is the first producer to ever exercise
514    /// — every earlier fragment use captured at most one call's worth of
515    /// output). When neither is active, scans the transcript from cursor
516    /// position.
517    fn has_content(&self) -> bool {
518        if self.capture_depth > 0 {
519            self.capture
520                .iter()
521                .rev()
522                .take_while(|p| !matches!(p, OutputPart::Checkpoint))
523                .any(OutputPart::is_content)
524        } else if self.fragment_depth > 0 {
525            self.fragment_capture
526                .iter()
527                .rev()
528                .take_while(|p| !matches!(p, OutputPart::Checkpoint))
529                .any(OutputPart::is_content)
530        } else {
531            self.transcript[self.cursor..]
532                .iter()
533                .rev()
534                .any(OutputPart::is_content)
535        }
536    }
537
538    /// Returns true if the unread transcript contains content or a Spring.
539    ///
540    /// This mirrors the C# runtime's `outputStreamContainsContent` check,
541    /// which returns true for ANY `StringValue` in the output stream.  In C#,
542    /// the choice target's `"^ "` (a space) is a `StringValue` — its brink
543    /// equivalent is `Spring`.  After `ResetOutput()` clears the stream at the
544    /// start of each `Continue()`, the choice target's space is the first thing
545    /// pushed, making `outputStreamContainsContent` true.  In brink, the
546    /// cursor advance at yield points has the same effect as `ResetOutput()`,
547    /// so checking unread parts mirrors the per-`Continue()` scope.
548    fn unread_has_content_or_spring(&self) -> bool {
549        self.transcript[self.cursor..]
550            .iter()
551            .any(|p| p.is_content() || matches!(p, OutputPart::Spring))
552    }
553
554    /// Returns true if the last part in the active target is a newline.
555    /// Same three-way priority as [`Self::has_content`] (issue #1839).
556    fn ends_in_newline(&self) -> bool {
557        let target = if self.capture_depth > 0 {
558            &self.capture
559        } else if self.fragment_depth > 0 {
560            &self.fragment_capture
561        } else {
562            &self.transcript
563        };
564        matches!(target.last(), Some(OutputPart::Newline))
565    }
566
567    /// Returns true if the last part is text ending with whitespace.
568    /// Only checks the immediately preceding part — intervening Glue or
569    /// Newline parts mean the glue system handles the join instead.
570    ///
571    /// `LineRef` is not inspected: `LineFlags` no longer carries an
572    /// edge-whitespace bit (`STARTS_WITH_WS`/`ENDS_WITH_WS` were removed —
573    /// they had no production consumer, and the C# reference runtime never
574    /// does sub-token leading/trailing whitespace detection either, so there
575    /// was no conformance gap to preserve). A resolved `LineRef` is treated
576    /// as not ending in whitespace, same as before this helper had any
577    /// `LineRef` case.
578    #[cfg(test)]
579    fn ends_in_whitespace(&self) -> bool {
580        let target = if self.capture_depth > 0 {
581            &self.capture
582        } else if self.fragment_depth > 0 {
583            &self.fragment_capture
584        } else {
585            &self.transcript
586        };
587        matches!(target.last(), Some(OutputPart::Text(s)) if s.ends_with(char::is_whitespace))
588    }
589
590    pub fn push_glue(&mut self) {
591        self.target().push(OutputPart::Glue);
592    }
593
594    /// Push a word break. Deduplicated: no consecutive Springs.
595    pub fn push_spring(&mut self) {
596        let target = self.target();
597        if !matches!(target.last(), Some(OutputPart::Spring)) {
598            target.push(OutputPart::Spring);
599        }
600    }
601
602    /// Push a deferred line reference. Resolved at read time.
603    /// Applies the same filtering as `push_text` using precomputed flags.
604    pub fn push_line_ref(
605        &mut self,
606        container_idx: u32,
607        line_idx: u16,
608        slots: Vec<Value>,
609        flags: brink_format::LineFlags,
610    ) {
611        // Suppress whitespace-only/empty content when there's no content yet.
612        if !self.has_content()
613            && (flags.contains(brink_format::LineFlags::ALL_WS)
614                || flags.contains(brink_format::LineFlags::EMPTY))
615        {
616            return;
617        }
618        self.target().push(OutputPart::LineRef {
619            container_idx,
620            line_idx,
621            slots,
622            flags,
623        });
624    }
625
626    /// Push a deferred value. Stringified at read time.
627    /// Null values are dropped (they stringify to empty string).
628    pub fn push_value_ref(&mut self, value: Value) {
629        if matches!(value, Value::Null) {
630            return;
631        }
632        // Suppress whitespace-only string values when there's no content yet.
633        if !self.has_content()
634            && let Value::String(ref s) = value
635            && s.trim().is_empty()
636        {
637            return;
638        }
639        self.target().push(OutputPart::ValueRef(value));
640    }
641
642    /// Push a tag associated with the current output line.
643    pub fn push_tag(&mut self, tag: String) {
644        self.target().push(OutputPart::Tag(tag));
645    }
646
647    /// Merge one field of an `attach = StructName` handler's return value
648    /// into the currently open run (issue #2108, `Opcode::AttachElement`'s
649    /// handler). See [`OutputPart::ElementAttach`]'s doc for why this is a
650    /// transcript entry rather than a `Flow`-level mutation.
651    pub(crate) fn push_element_attach(&mut self, key: String, value: String) {
652        self.target().push(OutputPart::ElementAttach(key, value));
653    }
654
655    /// Close the run the most recent [`Self::push_element_attach`] calls
656    /// opened (`Opcode::EndElementRun`'s handler). See
657    /// [`OutputPart::ElementAttachEnd`]'s doc.
658    pub(crate) fn push_element_attach_end(&mut self) {
659        self.target().push(OutputPart::ElementAttachEnd);
660    }
661
662    /// Returns true if a capture is currently active.
663    /// Whether a string-eval/tag/function-return capture is active — pushes
664    /// currently route to transient scratch, not visible output (NS-A2:
665    /// the `effect-trace` emit recorder's visibility guard; unused in
666    /// ordinary builds, hence the allow).
667    #[cfg_attr(not(feature = "effect-trace"), expect(dead_code))]
668    pub fn in_capture(&self) -> bool {
669        self.capture_depth > 0
670    }
671
672    pub fn has_checkpoint(&self) -> bool {
673        self.capture_depth > 0
674    }
675
676    /// Begin a capture. Pushes a checkpoint to the capture scratch space.
677    /// While a capture is active, all pushes route to the capture vec.
678    pub fn begin_capture(&mut self) {
679        self.capture_depth += 1;
680        self.capture.push(OutputPart::Checkpoint);
681    }
682
683    /// End the most recent capture: drain from the last checkpoint in the
684    /// capture vec, resolve glue, and return the result as a string.
685    ///
686    /// Returns `None` if there is no checkpoint.
687    pub fn end_capture(
688        &mut self,
689        program: &Program,
690        line_tables: &[Vec<LineEntry>],
691        resolver: Option<&dyn PluralResolver>,
692    ) -> Option<String> {
693        let cp_idx = self
694            .capture
695            .iter()
696            .rposition(|p| matches!(p, OutputPart::Checkpoint))?;
697
698        let captured: Vec<OutputPart> = self.capture.drain(cp_idx..).collect();
699        // Skip the checkpoint itself (first element).
700        let captured = &captured[1..];
701
702        self.capture_depth = self.capture_depth.saturating_sub(1);
703
704        Some(resolve_parts(
705            captured,
706            program,
707            line_tables,
708            resolver,
709            &self.fragments,
710        ))
711    }
712}
713
714/// First pass of glue resolution: mark newlines and glue parts for removal.
715///
716/// For each `Glue` part, find the nearest preceding `Newline` (skipping
717/// whitespace-only text, tags, checkpoints, and already-removed parts)
718/// and mark both the newline and the glue for removal.
719fn mark_glue_removals(parts: &[OutputPart], remove: &mut [bool]) {
720    for (i, part) in parts.iter().enumerate() {
721        if matches!(part, OutputPart::Glue) {
722            for j in (0..i).rev() {
723                if remove[j] {
724                    continue;
725                }
726                match &parts[j] {
727                    OutputPart::Newline => {
728                        remove[j] = true;
729                        break;
730                    }
731                    OutputPart::Glue
732                    | OutputPart::Checkpoint
733                    | OutputPart::Tag(_)
734                    | OutputPart::Spring
735                    | OutputPart::ElementAttach(..)
736                    | OutputPart::ElementAttachEnd
737                    // B4 (`docs/stdlib-spec.md` §1.6b): a final-`None`
738                    // value renders empty at the display boundary — same
739                    // pass-through treatment as whitespace-only text below,
740                    // consistent with `OutputPart::is_content`.
741                    | OutputPart::ValueRef(Value::OptionVal(None)) => {}
742                    OutputPart::Text(s) if s.trim().is_empty() => {}
743                    // Content (Text, LineRef, ValueRef) blocks glue scan.
744                    OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
745                        break;
746                    }
747                }
748            }
749            remove[i] = true;
750        }
751    }
752}
753
754/// Resolve glue in a slice of output parts and return the flattened string.
755///
756/// Mirrors [`resolve_lines_annotated`]'s per-line suppression (issue #2091,
757/// extended to this path by issue #2147 — the string-capture path #2091's
758/// PR #2140 did not touch): if a line within the captured text resolves
759/// fully empty and at least one of its parts interpolated a `content`-typed
760/// value (`Value::FragmentRef`) that itself rendered empty, the line is
761/// dropped entirely — not left behind as a blank line — same as the
762/// streaming/batch `resolve_lines` path.
763///
764/// `resolve_lines_annotated` does **not** call this function directly — the
765/// two hold independent copies of the same suppression logic, applied at
766/// different granularities. `resolve_parts`'s real callers are:
767///
768/// - [`OutputBuffer::end_capture`] — `Opcode::EndStringEval`'s resolution
769///   path (e.g. an unrecognized choice display, or any
770///   `~ temp x = "..."` string-eval capture);
771/// - [`OutputBuffer::resolve_fragment`] (`output/fragment.rs`) — the
772///   resolver `ChoiceDisplay::Fragment` reads through (`story/mod.rs`,
773///   `story/flow_instance.rs`), so a captured choice's display text is
774///   affected too (`brink-cli`'s `tui/app.rs` reads it from there);
775/// - [`resolve_part`]'s `ValueRef(Value::FragmentRef)` arm and
776///   [`resolve_line_parts`]'s `LinePart::Slot` `FragmentRef` arm — both
777///   recurse into `resolve_parts` to resolve a fragment's own *interior*,
778///   and both are themselves reachable from `resolve_lines_annotated`'s
779///   top-level resolution whenever a rendered line references a fragment.
780///   So this suppression also reaches inside any nested, multi-line
781///   fragment rendered on the streaming/batch path — a blank line
782///   contributed purely by an inner, rendered-empty fragment now vanishes
783///   from the *interior* of an outer fragment's captured text too, not
784///   only at a transcript line's own top level.
785///
786/// No `current_tags`-style tag exception is needed here (unlike
787/// `resolve_lines_annotated`): a `Tag` already sets `after_glue`, which
788/// unconditionally skips the newline right after it (pre-existing behavior,
789/// untouched by this fix) — so a tag-then-newline sequence never reaches
790/// this suppression check in the first place, and tags carry no characters
791/// into a captured string's text regardless.
792fn resolve_parts(
793    parts: &[OutputPart],
794    program: &Program,
795    line_tables: &[Vec<LineEntry>],
796    resolver: Option<&dyn PluralResolver>,
797    fragments: &[Fragment],
798) -> String {
799    // First pass: mark newlines that should be removed by glue.
800    let mut remove = vec![false; parts.len()];
801    mark_glue_removals(parts, &mut remove);
802
803    let mut out = String::new();
804    let mut after_glue = false;
805    // issue #2147: track the start of the current (in-progress) line within
806    // `out`, and whether it saw a `content`/Fragment interpolation, so a
807    // line that resolves fully empty purely from a rendered-empty fragment
808    // can be dropped rather than left as a stray blank line.
809    let mut line_start = 0usize;
810    let mut saw_fragment_ref = false;
811
812    for (i, part) in parts.iter().enumerate() {
813        if remove[i] {
814            if matches!(part, OutputPart::Glue) {
815                after_glue = true;
816            }
817            continue;
818        }
819        match part {
820            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
821                if part_involves_fragment_ref(part) {
822                    saw_fragment_ref = true;
823                }
824                let s = resolve_part(part, program, line_tables, resolver, fragments);
825                // Collapse adjacent whitespace at part boundaries.
826                let s = if s.starts_with(char::is_whitespace) && out.ends_with(char::is_whitespace)
827                {
828                    s.trim_start()
829                } else {
830                    &s
831                };
832                out.push_str(s);
833                if !s.trim().is_empty() {
834                    after_glue = false;
835                }
836            }
837            OutputPart::Spring => {
838                // Emit " " unless output is empty, ends in space, or ends in newline.
839                if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
840                    out.push(' ');
841                }
842            }
843            OutputPart::Newline => {
844                if !after_glue {
845                    let trimmed_len = out.trim_end_matches([' ', '\t']).len();
846                    out.truncate(trimmed_len);
847                    if saw_fragment_ref && out[line_start..].trim().is_empty() {
848                        // Suppress: drop the whole (whitespace-only) line
849                        // and its trailing newline, not just its text.
850                        out.truncate(line_start);
851                    } else {
852                        out.push('\n');
853                        line_start = out.len();
854                    }
855                    saw_fragment_ref = false;
856                }
857            }
858            OutputPart::Glue
859            | OutputPart::Checkpoint
860            | OutputPart::Tag(_)
861            | OutputPart::ElementAttach(..)
862            | OutputPart::ElementAttachEnd => {
863                after_glue = true;
864            }
865        }
866    }
867
868    // issue #2147 (trailing-entry parity with `resolve_lines_annotated`'s
869    // own `EXCEPTION (issue #2091)` handling of its final, unterminated
870    // entry): a captured string need not end on a `Newline` part. If the
871    // text since the last committed line resolves empty and interpolated a
872    // Fragment, drop it AND the newline that introduced it — mirroring how
873    // `resolve_lines` drops that trailing entry from its `Vec` whole (no
874    // join separator left behind for it either). Without this, parts like
875    // `[Text("a"), Newline, ValueRef(FragmentRef(<empty>))]` resolved to
876    // `"a\n"` here while `resolve_lines` (joining its per-line `Vec`, which
877    // dropped the suppressed trailing entry) produced just `"a"`.
878    if saw_fragment_ref && line_start > 0 && out[line_start..].trim().is_empty() {
879        out.truncate(line_start - 1);
880    }
881
882    out
883}
884
885/// Returns true if `part` interpolates a `content`-typed value
886/// (`Value::FragmentRef`) — either directly (`ValueRef`) or through a
887/// template `Slot` (`LineRef`).
888///
889/// Two distinct mechanisms produce a `FragmentRef` in this position, and
890/// this check does not — and structurally cannot — tell them apart: issue
891/// #1839's `block`-capture receiver, AND the ordinary display-position
892/// call-composition pattern `brink-codegen-inkb::content::emit_slot_expr`
893/// emits for *every* template slot whose expr is a function call
894/// (`lir::Expr::is_function_call()`, both dialects) — e.g. a line whose
895/// only content is `{ f() }`. Both are suppressed identically by the
896/// caller.
897///
898/// Purely structural: it does not need to look inside the referenced
899/// fragment to decide suppression. If a line's fully-resolved text comes
900/// out empty *and* one of its parts involved a fragment reference, that is
901/// sufficient evidence the fragment itself **rendered** empty. It does
902/// *not* follow that the fragment "captured nothing" — a fragment that
903/// captured a line which itself renders empty (e.g. an interpolated empty
904/// variable), or a call-composition fragment whose function simply
905/// returned `""`, both reach this same state. "Rendered empty" is the
906/// weaker, sufficient invariant suppression actually relies on.
907fn part_involves_fragment_ref(part: &OutputPart) -> bool {
908    match part {
909        OutputPart::LineRef { slots, .. } => {
910            slots.iter().any(|v| matches!(v, Value::FragmentRef(_)))
911        }
912        OutputPart::ValueRef(Value::FragmentRef(_)) => true,
913        _ => false,
914    }
915}
916
917/// Resolve glue and split into per-line output with associated tags and
918/// element-attachment data.
919///
920/// A resolved line: text, tags, and element-attachment data (issue #2108,
921/// [`OutputPart::ElementAttach`]'s own doc).
922pub(crate) type ResolvedLine = (String, Vec<String>, BTreeMap<String, String>);
923
924/// [`ResolvedLine`] plus the issue #2091 suppression flag —
925/// [`resolve_lines_annotated`]'s own unfiltered form.
926pub(crate) type AnnotatedResolvedLine = (String, Vec<String>, bool, BTreeMap<String, String>);
927
928/// Each returned element is `(line_text, line_tags, line_element_data)`.
929/// Tags reset every line; element-attachment data (issue #2108) persists
930/// across lines until an `ElementAttachEnd` closes the run — see
931/// [`OutputPart::ElementAttach`]'s own doc. Lines that
932/// [`resolve_lines_annotated`] marks suppressed (issue #2091 — an empty
933/// `content`/Fragment capture) are dropped entirely; nothing else changes.
934pub(crate) fn resolve_lines(
935    parts: &[OutputPart],
936    program: &Program,
937    line_tables: &[Vec<LineEntry>],
938    resolver: Option<&dyn PluralResolver>,
939    fragments: &[Fragment],
940) -> Vec<ResolvedLine> {
941    resolve_lines_annotated(
942        parts,
943        BTreeMap::new(),
944        program,
945        line_tables,
946        resolver,
947        fragments,
948    )
949    .into_iter()
950    .filter_map(|(text, tags, suppressed, element)| (!suppressed).then_some((text, tags, element)))
951    .collect()
952}
953
954/// Like [`resolve_lines`], but reports — per resolved line, as the trailing
955/// `bool` — whether it should be **suppressed** from reader-visible output:
956/// its fully-resolved text came out empty, it carries no tags, and at least
957/// one of its parts interpolated a `content`-typed value that itself
958/// rendered empty (issue #2091). Two distinct call sites produce that
959/// `Value::FragmentRef`, and this check treats them identically:
960///
961/// - issue #1839's `block`-capture receiver — e.g. a capture that
962///   terminated immediately because the next line was itself
963///   element-level (`hir::lower_native::element::capture_block`); and
964/// - the ordinary **display-position call-composition** pattern
965///   `brink-codegen-inkb::content::emit_slot_expr` emits
966///   (`BeginFragment`…`EndFragment`) for *every* template slot whose expr
967///   is a function call (`lir::Expr::is_function_call()`, both dialects) —
968///   e.g. a line whose only content is `{ f() }`, where `f` emits no
969///   side-effect text and returns an empty value.
970///
971/// See [`part_involves_fragment_ref`]'s own doc for why "the fragment
972/// rendered empty" is the invariant relied on here, not "the fragment
973/// captured nothing" — the two mechanisms above are exactly why the
974/// stronger claim does not hold.
975///
976/// This is a **read-time rendering decision only**: the line-table entry a
977/// suppressed line's `LineRef` points at is never touched, omitted, or
978/// renumbered — it stays present-but-empty, exactly as compiled, so
979/// locale hot-swap (which re-renders the *same* transcript against a
980/// swapped-in line vector, matched by index) keeps working unchanged. Only
981/// the rendered *output line* disappears; the underlying compiled data does
982/// not move.
983///
984/// A line that resolves empty for any OTHER reason — a literal blank line,
985/// or a self-closing inline markup span (`<pause/>`) with no children — is
986/// **not** suppressed: that is pre-existing, deliberate output (see the
987/// `inline-markup-point-marker` fixture, issue #1716), unrelated to this
988/// issue's scope of `content`/Fragment-driven emptiness.
989///
990/// [`OutputBuffer::take_first_line`] needs this unfiltered, index-aligned
991/// form — its single-newline slice always resolves to exactly two entries
992/// (the found line, then an always-empty trailing filler) — so it can tell
993/// "this line should be skipped, keep scanning for the next one" apart from
994/// "there is no completed line at all" without losing that index alignment
995/// (naively dropping the suppressed entry from the `Vec` would shift the
996/// filler into its place and return the very blank line being suppressed).
997///
998/// `seed_element` (issue #2108) is the element-attachment state already
999/// accumulated BEFORE `parts` starts — `take_first_line` passes its own
1000/// carried-forward [`OutputBuffer::pending_element`] here (a multi-line
1001/// attach run spans more than one `take_first_line` call, each resolving
1002/// only its own line's slice); every other caller passes an empty map,
1003/// since they resolve from a cold start. The trailing filler entry's own
1004/// element field is always the state at the END of `parts` — callers that
1005/// need to carry it forward (again, only `take_first_line`) read it from
1006/// there.
1007pub(crate) fn resolve_lines_annotated(
1008    parts: &[OutputPart],
1009    seed_element: BTreeMap<String, String>,
1010    program: &Program,
1011    line_tables: &[Vec<LineEntry>],
1012    resolver: Option<&dyn PluralResolver>,
1013    fragments: &[Fragment],
1014) -> Vec<AnnotatedResolvedLine> {
1015    if parts.is_empty() {
1016        return Vec::new();
1017    }
1018
1019    // First pass: mark newlines/glue for removal (same logic as resolve_parts).
1020    let mut remove = vec![false; parts.len()];
1021    mark_glue_removals(parts, &mut remove);
1022
1023    let mut lines: Vec<AnnotatedResolvedLine> = Vec::new();
1024    let mut current_text = String::new();
1025    let mut current_tags: Vec<String> = Vec::new();
1026    // Issue #2108: unlike `current_tags` (reset every line), this
1027    // ACCUMULATES across lines — cleared only by `ElementAttachEnd` — so
1028    // every line materialized while a run is open gets a copy (ruling item
1029    // 5). Cloned, never moved, into each pushed line entry below. Seeded
1030    // from the caller's already-accumulated state (see this function's own
1031    // `seed_element` doc) rather than always starting empty.
1032    let mut current_element: BTreeMap<String, String> = seed_element;
1033    let mut saw_fragment_ref = false;
1034    let mut after_glue = false;
1035
1036    for (i, part) in parts.iter().enumerate() {
1037        if remove[i] {
1038            if matches!(part, OutputPart::Glue) {
1039                after_glue = true;
1040            }
1041            continue;
1042        }
1043        match part {
1044            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
1045                if part_involves_fragment_ref(part) {
1046                    saw_fragment_ref = true;
1047                }
1048                let s = resolve_part(part, program, line_tables, resolver, fragments);
1049                // Collapse adjacent whitespace at part boundaries.
1050                let s = if s.starts_with(char::is_whitespace)
1051                    && current_text.ends_with(char::is_whitespace)
1052                {
1053                    s.trim_start()
1054                } else {
1055                    &s
1056                };
1057                current_text.push_str(s);
1058                if !s.trim().is_empty() {
1059                    after_glue = false;
1060                }
1061            }
1062            OutputPart::Spring => {
1063                if !current_text.is_empty()
1064                    && !current_text.ends_with(' ')
1065                    && !current_text.ends_with('\n')
1066                {
1067                    current_text.push(' ');
1068                }
1069            }
1070            OutputPart::Newline => {
1071                if !after_glue {
1072                    let trimmed = current_text.trim().to_string();
1073                    let suppressed =
1074                        trimmed.is_empty() && current_tags.is_empty() && saw_fragment_ref;
1075                    lines.push((
1076                        trimmed,
1077                        mem::take(&mut current_tags),
1078                        suppressed,
1079                        current_element.clone(),
1080                    ));
1081                    current_text = String::new();
1082                    saw_fragment_ref = false;
1083                }
1084            }
1085            OutputPart::Tag(tag) => {
1086                current_tags.push(tag.clone());
1087            }
1088            OutputPart::ElementAttach(key, value) => {
1089                current_element.insert(key.clone(), value.clone());
1090            }
1091            OutputPart::ElementAttachEnd => {
1092                current_element.clear();
1093            }
1094            OutputPart::Glue | OutputPart::Checkpoint => {
1095                after_glue = true;
1096            }
1097        }
1098    }
1099
1100    // Push the final line — even if empty — so that a trailing Newline
1101    // part produces a trailing `\n` when the lines are joined by
1102    // `resolve_lines`'s callers (e.g. `flush_remaining`'s `\n`-join over
1103    // consecutive entries).
1104    //
1105    // EXCEPTION (issue #2091): this final entry is itself eligible for
1106    // suppression like any other — if the transcript's unread tail ends
1107    // with an unterminated fragment-bearing segment that resolves empty
1108    // (no following `Newline`), `suppressed` is `true` here too, and
1109    // `resolve_lines` drops this entry from its `Vec` entirely rather than
1110    // keeping it as a `("", [])` placeholder. When that happens, the
1111    // trailing-`\n`-via-empty-final-entry guarantee this comment describes
1112    // does NOT hold for the preceding real line — there is no longer a
1113    // placeholder entry left for a caller's join loop to add a separator
1114    // before. This is accepted, not additionally special-cased: it only
1115    // arises when the story's last visible output is itself an empty
1116    // `content`/Fragment interpolation, which is precisely the case this
1117    // issue suppresses.
1118    let trimmed = current_text.trim().to_string();
1119    let suppressed = trimmed.is_empty() && current_tags.is_empty() && saw_fragment_ref;
1120    lines.push((trimmed, current_tags, suppressed, current_element));
1121
1122    lines
1123}
1124
1125/// Create a minimal `Program` for tests that only use `Text`/`Newline`/`Glue`.
1126#[cfg(test)]
1127fn test_dummy_program() -> Program {
1128    use std::collections::HashMap;
1129    Program {
1130        containers: vec![],
1131        address_map: HashMap::new(),
1132        scope_ids: vec![],
1133        source_checksum: 0,
1134        globals: vec![],
1135        global_map: HashMap::new(),
1136        name_table: vec![],
1137        address_by_path: HashMap::new(),
1138        root_idx: 0,
1139        list_literals: vec![],
1140        literal_pool: vec![],
1141        list_item_map: HashMap::new(),
1142        list_defs: vec![],
1143        list_def_map: HashMap::new(),
1144        external_fns: HashMap::new(),
1145        local_scope_defaults: Vec::new(),
1146        struct_shapes: Vec::new(),
1147        private_defs: Vec::new(),
1148        alias_table: Vec::new(),
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    /// Test helpers — `OutputBuffer` methods that need resolution context.
1157    /// Tests only use Text/Newline/Glue, so we pass an empty program.
1158    impl OutputBuffer {
1159        fn test_flush_lines(&mut self) -> Vec<(String, Vec<String>)> {
1160            let p = test_dummy_program();
1161            // Element-attachment data (issue #2108) is dropped here — none
1162            // of these pre-existing tests exercise attach conventions, and
1163            // widening every existing `(text, tags)` assertion in this
1164            // module for a field they never populate would just be noise.
1165            // `crates/brink-runtime/tests/element.rs` exercises the real
1166            // per-line element data end to end instead.
1167            self.flush_lines(&p, &[], None)
1168                .into_iter()
1169                .map(|(text, tags, _element)| (text, tags))
1170                .collect()
1171        }
1172
1173        fn test_take_first_line(&mut self) -> Option<(String, Vec<String>)> {
1174            let p = test_dummy_program();
1175            self.take_first_line(&p, &[], None)
1176                .map(|(text, tags, _element)| (text, tags))
1177        }
1178
1179        fn test_end_capture(&mut self) -> Option<String> {
1180            let p = test_dummy_program();
1181            self.end_capture(&p, &[], None)
1182        }
1183    }
1184
1185    #[test]
1186    fn simple_text() {
1187        let mut buf = OutputBuffer::new();
1188        buf.push_text("hello");
1189        assert_eq!(buf.flush(), "hello");
1190    }
1191
1192    #[test]
1193    fn text_with_newline() {
1194        let mut buf = OutputBuffer::new();
1195        buf.push_text("hello");
1196        buf.push_newline();
1197        buf.push_text("world");
1198        assert_eq!(buf.flush(), "hello\nworld");
1199    }
1200
1201    #[test]
1202    fn glue_removes_newline() {
1203        let mut buf = OutputBuffer::new();
1204        buf.push_text("hello");
1205        buf.push_newline();
1206        buf.push_glue();
1207        buf.push_text("world");
1208        assert_eq!(buf.flush(), "helloworld");
1209    }
1210
1211    #[test]
1212    fn glue_preserves_leading_whitespace_in_text() {
1213        let mut buf = OutputBuffer::new();
1214        buf.push_text("hello");
1215        buf.push_newline();
1216        buf.push_glue();
1217        buf.push_text("  world");
1218        assert_eq!(buf.flush(), "hello  world");
1219    }
1220
1221    #[test]
1222    fn double_flush_is_empty() {
1223        let mut buf = OutputBuffer::new();
1224        buf.push_text("hello");
1225        let _ = buf.flush();
1226        assert_eq!(buf.flush(), "");
1227    }
1228
1229    #[test]
1230    fn leading_newline_suppressed() {
1231        let mut buf = OutputBuffer::new();
1232        buf.push_newline();
1233        buf.push_text("hello");
1234        assert_eq!(buf.flush(), "hello");
1235    }
1236
1237    /// Leading whitespace-only text at the start of output (no prior content)
1238    /// should be suppressed, just like leading newlines are suppressed.
1239    /// This happens after choice selection: choice bodies start with `"^ "`.
1240    #[test]
1241    fn leading_whitespace_only_text_suppressed() {
1242        let mut buf = OutputBuffer::new();
1243        buf.push_text(" ");
1244        buf.push_text("hello");
1245        assert_eq!(buf.flush(), "hello");
1246    }
1247
1248    /// Leading whitespace-only text after a flush should also be suppressed.
1249    /// Adjacent whitespace at text boundaries should collapse.
1250    /// E.g., start content "Hello " + inner content " right back" → "Hello right back".
1251    #[test]
1252    fn adjacent_whitespace_collapsed() {
1253        let mut buf = OutputBuffer::new();
1254        buf.push_text("Hello ");
1255        buf.push_text(" right back");
1256        assert_eq!(buf.flush(), "Hello right back");
1257    }
1258
1259    #[test]
1260    fn leading_whitespace_after_flush_suppressed() {
1261        let mut buf = OutputBuffer::new();
1262        buf.push_text("first");
1263        let _ = buf.flush();
1264        buf.push_text("  ");
1265        buf.push_text("second");
1266        assert_eq!(buf.flush(), "second");
1267    }
1268
1269    #[test]
1270    fn duplicate_newline_suppressed() {
1271        let mut buf = OutputBuffer::new();
1272        buf.push_text("hello");
1273        buf.push_newline();
1274        buf.push_newline();
1275        buf.push_text("world");
1276        assert_eq!(buf.flush(), "hello\nworld");
1277    }
1278
1279    #[test]
1280    fn leading_newline_after_flush_suppressed() {
1281        let mut buf = OutputBuffer::new();
1282        buf.push_text("first");
1283        let _ = buf.flush();
1284        // After flush, buffer is empty again — leading newline should be suppressed.
1285        buf.push_newline();
1286        buf.push_text("second");
1287        assert_eq!(buf.flush(), "second");
1288    }
1289
1290    #[test]
1291    fn begin_end_capture_basic() {
1292        let mut buf = OutputBuffer::new();
1293        buf.push_text("before");
1294        buf.begin_capture();
1295        buf.push_text("captured");
1296        let result = buf.test_end_capture();
1297        assert_eq!(result, Some("captured".to_owned()));
1298        assert_eq!(buf.flush(), "before");
1299    }
1300
1301    #[test]
1302    fn nested_captures() {
1303        let mut buf = OutputBuffer::new();
1304        buf.push_text("outer");
1305        buf.begin_capture();
1306        buf.push_text("middle");
1307        buf.begin_capture();
1308        buf.push_text("inner");
1309        let inner = buf.test_end_capture();
1310        assert_eq!(inner, Some("inner".to_owned()));
1311        let middle = buf.test_end_capture();
1312        assert_eq!(middle, Some("middle".to_owned()));
1313        assert_eq!(buf.flush(), "outer");
1314    }
1315
1316    #[test]
1317    fn capture_with_glue() {
1318        let mut buf = OutputBuffer::new();
1319        buf.begin_capture();
1320        buf.push_text("hello");
1321        buf.push_newline();
1322        buf.push_glue();
1323        buf.push_text(" world");
1324        let result = buf.test_end_capture();
1325        assert_eq!(result, Some("hello world".to_owned()));
1326    }
1327
1328    #[test]
1329    fn end_capture_no_checkpoint_returns_none() {
1330        let mut buf = OutputBuffer::new();
1331        buf.push_text("hello");
1332        assert_eq!(buf.test_end_capture(), None);
1333    }
1334
1335    #[test]
1336    fn has_content_respects_checkpoint() {
1337        let mut buf = OutputBuffer::new();
1338        buf.push_text("before");
1339        buf.begin_capture();
1340        // No content after the checkpoint.
1341        assert!(!buf.has_content());
1342        buf.push_text("after");
1343        assert!(buf.has_content());
1344    }
1345
1346    /// Glue should eat the following newline, not just the preceding one.
1347    /// Pattern: `<>-<>` where glue appears on both sides of the dash.
1348    #[test]
1349    fn glue_eats_following_newline() {
1350        let mut buf = OutputBuffer::new();
1351        buf.push_text("fifty");
1352        buf.push_newline();
1353        buf.push_glue();
1354        buf.push_text("-");
1355        buf.push_glue();
1356        buf.push_newline();
1357        buf.push_text("eight");
1358        assert_eq!(buf.flush(), "fifty-eight");
1359    }
1360
1361    /// Trailing whitespace before a newline should be trimmed.
1362    /// Pattern: `A {f():B}⏎X` where `f()` returns false — the space after
1363    /// "A" becomes trailing whitespace when the inline expression produces
1364    /// no output.
1365    #[test]
1366    fn trailing_whitespace_before_newline_trimmed() {
1367        let mut buf = OutputBuffer::new();
1368        buf.push_text("A ");
1369        buf.push_newline();
1370        buf.push_text("X");
1371        assert_eq!(buf.flush(), "A\nX");
1372    }
1373
1374    /// Glue should NOT trim leading whitespace from text content.
1375    /// Pattern: `Some <>⏎content<> with glue.`
1376    /// The space in " with glue." is content, not indentation.
1377    #[test]
1378    fn glue_preserves_text_whitespace() {
1379        let mut buf = OutputBuffer::new();
1380        buf.push_text("Some ");
1381        buf.push_glue();
1382        buf.push_newline();
1383        buf.push_text("content");
1384        buf.push_glue();
1385        buf.push_text(" with glue.");
1386        assert_eq!(buf.flush(), "Some content with glue.");
1387    }
1388
1389    /// Glue should skip past whitespace-only text to find the preceding newline.
1390    /// Pattern: `a\n" "<>b` — the `" "` is whitespace-only and should not block
1391    /// the glue from removing the newline.
1392    #[test]
1393    fn glue_skips_whitespace_only_text_to_find_newline() {
1394        let mut buf = OutputBuffer::new();
1395        buf.push_text("a");
1396        buf.push_newline();
1397        buf.push_text(" ");
1398        buf.push_glue();
1399        buf.push_text("b");
1400        assert_eq!(buf.flush(), "a b");
1401    }
1402
1403    // ── flush_lines tests ────────────────────────────────────────────
1404
1405    /// Tags should associate with the line they appear on.
1406    #[test]
1407    fn flush_lines_associates_tags_with_lines() {
1408        let mut buf = OutputBuffer::new();
1409        buf.push_text("line one");
1410        buf.push_newline();
1411        buf.push_text("line two");
1412        buf.push_tag("my_tag".to_string());
1413        buf.push_newline();
1414        buf.push_text("line three");
1415        let lines = buf.test_flush_lines();
1416        assert_eq!(lines.len(), 3);
1417        assert_eq!(lines[0].0, "line one");
1418        assert!(lines[0].1.is_empty());
1419        assert_eq!(lines[1].0, "line two");
1420        assert_eq!(lines[1].1, vec!["my_tag"]);
1421        assert_eq!(lines[2].0, "line three");
1422        assert!(lines[2].1.is_empty());
1423    }
1424
1425    /// Tags on the last line (no trailing newline) should still be captured.
1426    #[test]
1427    fn flush_lines_tag_on_last_line() {
1428        let mut buf = OutputBuffer::new();
1429        buf.push_text("only line");
1430        buf.push_tag("t".to_string());
1431        let lines = buf.test_flush_lines();
1432        assert_eq!(lines.len(), 1);
1433        assert_eq!(lines[0].0, "only line");
1434        assert_eq!(lines[0].1, vec!["t"]);
1435    }
1436
1437    /// `flush_lines` should resolve glue the same as `flush`.
1438    #[test]
1439    fn flush_lines_resolves_glue() {
1440        let mut buf = OutputBuffer::new();
1441        buf.push_text("hello");
1442        buf.push_newline();
1443        buf.push_glue();
1444        buf.push_text(" world");
1445        let lines = buf.test_flush_lines();
1446        assert_eq!(lines.len(), 1);
1447        assert_eq!(lines[0].0, "hello world");
1448    }
1449
1450    /// Flushing an empty buffer should return no lines.
1451    /// A spurious `[("", [])]` from an empty buffer causes leading `\n`
1452    /// when `step_with` calls `flush_lines` multiple times (e.g., before
1453    /// auto-selecting invisible default choices).
1454    #[test]
1455    fn flush_lines_empty_buffer_returns_no_lines() {
1456        let mut buf = OutputBuffer::new();
1457        let lines = buf.test_flush_lines();
1458        assert!(
1459            lines.is_empty(),
1460            "empty buffer should produce no lines, got: {lines:?}"
1461        );
1462    }
1463
1464    // ── has_completed_line / take_first_line tests ──────────────────
1465
1466    #[test]
1467    fn has_completed_line_empty() {
1468        let buf = OutputBuffer::new();
1469        assert!(!buf.has_completed_line());
1470    }
1471
1472    #[test]
1473    fn has_completed_line_text_only() {
1474        let mut buf = OutputBuffer::new();
1475        buf.push_text("hello");
1476        assert!(!buf.has_completed_line());
1477    }
1478
1479    #[test]
1480    fn has_completed_line_text_newline_only() {
1481        let mut buf = OutputBuffer::new();
1482        buf.push_text("hello");
1483        buf.push_newline();
1484        // No content after the newline → not committed.
1485        assert!(!buf.has_completed_line());
1486    }
1487
1488    #[test]
1489    fn has_completed_line_text_newline_text() {
1490        let mut buf = OutputBuffer::new();
1491        buf.push_text("hello");
1492        buf.push_newline();
1493        buf.push_text("world");
1494        assert!(buf.has_completed_line());
1495    }
1496
1497    #[test]
1498    fn has_completed_line_glue_eats_newline() {
1499        let mut buf = OutputBuffer::new();
1500        buf.push_text("hello");
1501        buf.push_newline();
1502        buf.push_glue();
1503        buf.push_text("world");
1504        // Glue eats the newline → no committed newline.
1505        assert!(!buf.has_completed_line());
1506    }
1507
1508    #[test]
1509    fn has_completed_line_during_capture() {
1510        let mut buf = OutputBuffer::new();
1511        buf.push_text("hello");
1512        buf.push_newline();
1513        buf.push_text("world");
1514        buf.begin_capture();
1515        // Active capture → not available for line extraction.
1516        assert!(!buf.has_completed_line());
1517    }
1518
1519    #[test]
1520    fn take_first_line_basic() {
1521        let mut buf = OutputBuffer::new();
1522        buf.push_text("hello");
1523        buf.push_newline();
1524        buf.push_text("world");
1525
1526        let result = buf.test_take_first_line();
1527        assert!(result.is_some());
1528        let (text, tags) = result.unwrap();
1529        assert_eq!(text, "hello\n");
1530        assert!(tags.is_empty());
1531
1532        // Remainder should produce "world" when flushed.
1533        assert_eq!(buf.flush(), "world");
1534    }
1535
1536    #[test]
1537    fn take_first_line_with_tags() {
1538        let mut buf = OutputBuffer::new();
1539        buf.push_text("tagged line");
1540        buf.push_tag("my_tag".to_string());
1541        buf.push_newline();
1542        buf.push_text("next line");
1543
1544        let (text, tags) = buf.test_take_first_line().unwrap();
1545        assert_eq!(text, "tagged line\n");
1546        assert_eq!(tags, vec!["my_tag"]);
1547
1548        assert_eq!(buf.flush(), "next line");
1549    }
1550
1551    #[test]
1552    fn take_first_line_multiple_lines() {
1553        let mut buf = OutputBuffer::new();
1554        buf.push_text("line one");
1555        buf.push_newline();
1556        buf.push_text("line two");
1557        buf.push_newline();
1558        buf.push_text("line three");
1559
1560        let (text1, _) = buf.test_take_first_line().unwrap();
1561        assert_eq!(text1, "line one\n");
1562
1563        let (text2, _) = buf.test_take_first_line().unwrap();
1564        assert_eq!(text2, "line two\n");
1565
1566        // Only "line three" remains, no newline after it → no completed line.
1567        assert!(!buf.has_completed_line());
1568        assert_eq!(buf.flush(), "line three");
1569    }
1570
1571    #[test]
1572    fn take_first_line_matches_flush_lines() {
1573        // Verify take_first_line produces the same first line as flush_lines.
1574        let parts = |buf: &mut OutputBuffer| {
1575            buf.push_text("A ");
1576            buf.push_tag("t1".to_string());
1577            buf.push_newline();
1578            buf.push_text("B");
1579            buf.push_newline();
1580            buf.push_text("C");
1581        };
1582
1583        let mut buf1 = OutputBuffer::new();
1584        parts(&mut buf1);
1585        let all_lines = buf1.test_flush_lines();
1586        let first_from_flush = &all_lines[0].0;
1587
1588        let mut buf2 = OutputBuffer::new();
1589        parts(&mut buf2);
1590        let (first_from_take, tags) = buf2.test_take_first_line().unwrap();
1591        // take_first_line appends \n; strip it for comparison.
1592        let first_trimmed = first_from_take.trim_end_matches('\n');
1593
1594        assert_eq!(first_trimmed, first_from_flush);
1595        assert_eq!(tags, all_lines[0].1);
1596    }
1597
1598    #[test]
1599    fn take_first_line_glue_preserves_subsequent() {
1600        // Glue eats the first newline; second newline survives.
1601        let mut buf = OutputBuffer::new();
1602        buf.push_text("hello");
1603        buf.push_newline();
1604        buf.push_glue();
1605        buf.push_text(" world");
1606        buf.push_newline();
1607        buf.push_text("next");
1608
1609        let (text, _) = buf.test_take_first_line().unwrap();
1610        assert_eq!(text, "hello world\n");
1611        assert_eq!(buf.flush(), "next");
1612    }
1613
1614    #[test]
1615    fn take_first_line_none_when_empty() {
1616        let mut buf = OutputBuffer::new();
1617        assert!(buf.test_take_first_line().is_none());
1618    }
1619
1620    #[test]
1621    fn take_first_line_none_when_no_newline() {
1622        let mut buf = OutputBuffer::new();
1623        buf.push_text("no newline");
1624        assert!(buf.test_take_first_line().is_none());
1625    }
1626
1627    // ── resolve_line_ref template collapsing tests ────────────────────
1628
1629    /// Build a minimal `Program` with one container (`scope_table_idx` = 0)
1630    /// and a line table with a single template entry, then resolve it.
1631    fn resolve_template(parts: Vec<LinePart>, slots: &[Value]) -> String {
1632        use crate::program::LinkedContainer;
1633        use brink_format::{CountingFlags, DefinitionId, DefinitionTag, LineEntry, LineFlags};
1634        use std::collections::HashMap;
1635
1636        let id = DefinitionId::new(DefinitionTag::Address, 0);
1637        let program = Program {
1638            containers: vec![LinkedContainer {
1639                id,
1640                bytecode: vec![],
1641                counting_flags: CountingFlags::empty(),
1642                path_hash: 0,
1643                param_count: 0,
1644                params: Vec::new(),
1645                scope_table_idx: 0,
1646            }],
1647            address_map: HashMap::new(),
1648            scope_ids: vec![id],
1649            source_checksum: 0,
1650            globals: vec![],
1651            global_map: HashMap::new(),
1652            name_table: vec![],
1653            address_by_path: HashMap::new(),
1654            root_idx: 0,
1655            list_literals: vec![],
1656            literal_pool: vec![],
1657            list_item_map: HashMap::new(),
1658            list_defs: vec![],
1659            list_def_map: HashMap::new(),
1660            external_fns: HashMap::new(),
1661            local_scope_defaults: Vec::new(),
1662            struct_shapes: Vec::new(),
1663            private_defs: Vec::new(),
1664            alias_table: Vec::new(),
1665        };
1666
1667        let line_tables = vec![vec![LineEntry {
1668            content: LineContent::Template(parts),
1669            source_hash: 0,
1670            flags: LineFlags::empty(),
1671            audio_ref: None,
1672            slot_info: vec![],
1673            source_location: None,
1674        }]];
1675
1676        resolve_line_ref(&program, &line_tables, 0, 0, slots, None, &[])
1677    }
1678
1679    #[test]
1680    fn template_collapses_double_space_from_empty_slot() {
1681        let result = resolve_template(
1682            vec![
1683                LinePart::Literal("Hello ".into()),
1684                LinePart::Slot(0),
1685                LinePart::Literal(" world".into()),
1686            ],
1687            &[Value::Null],
1688        );
1689        assert_eq!(result, "Hello world");
1690    }
1691
1692    #[test]
1693    fn template_preserves_spaces_with_nonempty_slot() {
1694        let result = resolve_template(
1695            vec![
1696                LinePart::Literal("Hello ".into()),
1697                LinePart::Slot(0),
1698                LinePart::Literal(" world".into()),
1699            ],
1700            &[Value::String("dear".into())],
1701        );
1702        assert_eq!(result, "Hello dear world");
1703    }
1704
1705    #[test]
1706    fn template_multiple_empty_slots_collapse() {
1707        let result = resolve_template(
1708            vec![
1709                LinePart::Literal("a ".into()),
1710                LinePart::Slot(0),
1711                LinePart::Literal(" ".into()),
1712                LinePart::Slot(1),
1713                LinePart::Literal(" b".into()),
1714            ],
1715            &[Value::Null, Value::Null],
1716        );
1717        assert_eq!(result, "a b");
1718    }
1719
1720    #[test]
1721    fn template_empty_string_slot_same_as_null() {
1722        let result = resolve_template(
1723            vec![
1724                LinePart::Literal("Hello ".into()),
1725                LinePart::Slot(0),
1726                LinePart::Literal(" world".into()),
1727            ],
1728            &[Value::String("".into())],
1729        );
1730        assert_eq!(result, "Hello world");
1731    }
1732
1733    // ── Inline markup spans (#1716, docs/prose-dialect-spec.md §4) ─────
1734    //
1735    // No structured `Part::Span` consumer surface exists yet (§7/§9.1 ⏳)
1736    // — a span resolves to its children's concatenated text, tag name/
1737    // attrs stripped, recursing through the same `resolve_line_parts` a
1738    // plain Template does.
1739
1740    #[test]
1741    fn span_resolves_to_its_children_text_tag_stripped() {
1742        let result = resolve_template(
1743            vec![
1744                LinePart::Literal("Hello ".into()),
1745                LinePart::Span {
1746                    name: "wave".into(),
1747                    attrs: vec![],
1748                    children: vec![LinePart::Literal("world".into())],
1749                },
1750            ],
1751            &[],
1752        );
1753        assert_eq!(result, "Hello world");
1754    }
1755
1756    #[test]
1757    fn a_self_closing_span_with_no_children_resolves_to_nothing() {
1758        let result = resolve_template(
1759            vec![
1760                LinePart::Literal("Bell tolls. ".into()),
1761                LinePart::Span {
1762                    name: "pause".into(),
1763                    attrs: vec![],
1764                    children: vec![],
1765                },
1766                LinePart::Literal(" Door slams.".into()),
1767            ],
1768            &[],
1769        );
1770        assert_eq!(result, "Bell tolls. Door slams.");
1771    }
1772
1773    #[test]
1774    fn a_span_containing_a_slot_resolves_the_slot() {
1775        let result = resolve_template(
1776            vec![LinePart::Span {
1777                name: "b".into(),
1778                attrs: vec![],
1779                children: vec![LinePart::Literal("hello ".into()), LinePart::Slot(0)],
1780            }],
1781            &[Value::String("Fogg".into())],
1782        );
1783        assert_eq!(result, "hello Fogg");
1784    }
1785
1786    #[test]
1787    fn nested_spans_resolve_recursively() {
1788        let result = resolve_template(
1789            vec![LinePart::Span {
1790                name: "b".into(),
1791                attrs: vec![],
1792                children: vec![LinePart::Span {
1793                    name: "i".into(),
1794                    attrs: vec![],
1795                    children: vec![LinePart::Literal("hi".into())],
1796                }],
1797            }],
1798            &[],
1799        );
1800        assert_eq!(result, "hi");
1801    }
1802
1803    // ── B4 display-boundary forgiveness (`docs/stdlib-spec.md` §1.6b) ──
1804
1805    /// A final-`None` template slot renders as nothing — the surrounding
1806    /// whitespace collapses exactly like the pre-existing `Null`/empty-
1807    /// string slot cases above.
1808    #[test]
1809    fn template_none_option_slot_renders_as_nothing() {
1810        let result = resolve_template(
1811            vec![
1812                LinePart::Literal("Hello ".into()),
1813                LinePart::Slot(0),
1814                LinePart::Literal(" world".into()),
1815            ],
1816            &[Value::none()],
1817        );
1818        assert_eq!(result, "Hello world");
1819    }
1820
1821    /// `Some(v)` at the same slot position is unaffected by the boundary —
1822    /// still `some(<v>)`, the F28 total rendering `stringify` gives it.
1823    #[test]
1824    fn template_some_option_slot_renders_totally() {
1825        let result = resolve_template(
1826            vec![LinePart::Literal("val: ".into()), LinePart::Slot(0)],
1827            &[Value::some(Value::Int(3))],
1828        );
1829        assert_eq!(result, "val: some(3)");
1830    }
1831
1832    /// A bare `OutputPart::ValueRef` (the `EmitValue`/unrecognized-content
1833    /// path, not a template slot) gets the same forgiveness — and the
1834    /// surrounding whitespace collapses across it exactly like it already
1835    /// does across an eagerly-dropped `Value::Null` or an empty string
1836    /// (`adjacent_whitespace_collapsed`, above): "before " + (nothing) +
1837    /// " after" reads as one collapsed space, not two.
1838    #[test]
1839    fn value_ref_none_option_renders_as_nothing() {
1840        let mut buf = OutputBuffer::new();
1841        buf.push_text("before ");
1842        buf.push_value_ref(Value::none());
1843        buf.push_text(" after");
1844        assert_eq!(buf.flush(), "before after");
1845    }
1846
1847    /// Traceability rider (§1.6b): the append-only transcript is never
1848    /// eagerly resolved (`docs/runtime-restructuring-spec.md`'s
1849    /// deferred-resolution model) — a forgiven `None`-render still shows up
1850    /// as `Value::OptionVal(None)` in `transcript()`, distinct from a slot
1851    /// that carried no value at all. Resolving to text loses the
1852    /// information; the structural transcript never does.
1853    #[test]
1854    fn none_render_is_traceable_in_the_raw_transcript() {
1855        let mut buf = OutputBuffer::new();
1856        buf.push_value_ref(Value::none());
1857        assert!(
1858            buf.transcript()
1859                .iter()
1860                .any(|p| matches!(p, OutputPart::ValueRef(Value::OptionVal(None)))),
1861            "the raw None value must survive in the transcript: {:?}",
1862            buf.transcript()
1863        );
1864        // Resolving it, separately, gives the forgiven empty text.
1865        assert_eq!(buf.flush(), "");
1866    }
1867
1868    /// A leading `None`-rendering value must not count as content for
1869    /// leading-newline suppression — otherwise a story that opens with a
1870    /// forgiven interpolation would get a spurious blank line before its
1871    /// real content.
1872    #[test]
1873    fn leading_none_option_value_does_not_block_newline_suppression() {
1874        let mut buf = OutputBuffer::new();
1875        buf.push_value_ref(Value::none());
1876        buf.push_newline();
1877        buf.push_text("hello");
1878        assert_eq!(buf.flush(), "hello");
1879    }
1880
1881    /// A `None`-rendering value between glue and its target newline must
1882    /// not block the glue scan — it passes through like whitespace-only
1883    /// text, matching `mark_glue_removals`'s existing arms.
1884    #[test]
1885    fn none_option_value_does_not_block_glue_scan() {
1886        let mut buf = OutputBuffer::new();
1887        buf.push_text("hello");
1888        buf.push_newline();
1889        buf.push_value_ref(Value::none());
1890        buf.push_glue();
1891        buf.push_text("world");
1892        assert_eq!(buf.flush(), "helloworld");
1893    }
1894
1895    // ── #2091: suppress a blank line from an empty content/Fragment capture ──
1896    //
1897    // A `block`-capturing handler (issue #1839) whose captured run is empty —
1898    // e.g. a cue immediately followed by a parenthetical, so
1899    // `hir::lower_native::element::capture_block` finds zero interior lines
1900    // — still binds its `content`-typed parameter to a real (empty)
1901    // `Value::FragmentRef`. Interpolating that alone on a template line
1902    // (`{body}` in a prose-ground handler body) used to render its own
1903    // visible blank line. These tests exercise the fix directly against the
1904    // output-resolution layer, independent of the full compiler pipeline
1905    // (see `tests/tier1-native/conventions-screenplay-preset/` for the e2e
1906    // golden fixture this same fix corrects).
1907
1908    /// Build a minimal one-container `Program` plus a matching line table
1909    /// from a caller-supplied list of `LineEntry`s (indices become
1910    /// `line_idx`), for `resolve_lines`/`take_first_line` tests that need
1911    /// more than `resolve_template`'s single entry.
1912    fn program_with_line_table(entries: Vec<LineEntry>) -> (Program, Vec<Vec<LineEntry>>) {
1913        use crate::program::LinkedContainer;
1914        use brink_format::{CountingFlags, DefinitionId, DefinitionTag};
1915        use std::collections::HashMap;
1916
1917        let id = DefinitionId::new(DefinitionTag::Address, 0);
1918        let program = Program {
1919            containers: vec![LinkedContainer {
1920                id,
1921                bytecode: vec![],
1922                counting_flags: CountingFlags::empty(),
1923                path_hash: 0,
1924                param_count: 0,
1925                params: Vec::new(),
1926                scope_table_idx: 0,
1927            }],
1928            address_map: HashMap::new(),
1929            scope_ids: vec![id],
1930            source_checksum: 0,
1931            globals: vec![],
1932            global_map: HashMap::new(),
1933            name_table: vec![],
1934            address_by_path: HashMap::new(),
1935            root_idx: 0,
1936            list_literals: vec![],
1937            literal_pool: vec![],
1938            list_item_map: HashMap::new(),
1939            list_defs: vec![],
1940            list_def_map: HashMap::new(),
1941            external_fns: HashMap::new(),
1942            local_scope_defaults: Vec::new(),
1943            struct_shapes: Vec::new(),
1944            private_defs: Vec::new(),
1945            alias_table: Vec::new(),
1946        };
1947        (program, vec![entries])
1948    }
1949
1950    fn plain_entry(s: &str) -> LineEntry {
1951        LineEntry {
1952            content: LineContent::Plain(s.to_string()),
1953            source_hash: 0,
1954            flags: brink_format::LineFlags::from_plain(s),
1955            audio_ref: None,
1956            slot_info: vec![],
1957            source_location: None,
1958        }
1959    }
1960
1961    fn one_slot_template_entry() -> LineEntry {
1962        LineEntry {
1963            content: LineContent::Template(vec![LinePart::Slot(0)]),
1964            source_hash: 0,
1965            // A Slot always defeats the compile-time conservative flags —
1966            // see `LineFlags::from_template`'s own doc/tests.
1967            flags: brink_format::LineFlags::empty(),
1968            audio_ref: None,
1969            slot_info: vec![],
1970            source_location: None,
1971        }
1972    }
1973
1974    fn line_ref(line_idx: u16, slots: Vec<Value>, flags: brink_format::LineFlags) -> OutputPart {
1975        OutputPart::LineRef {
1976            container_idx: 0,
1977            line_idx,
1978            slots,
1979            flags,
1980        }
1981    }
1982
1983    #[test]
1984    fn resolve_lines_suppresses_a_blank_line_from_an_empty_content_capture() {
1985        // line 0: "VENDOR", line 1: `{body}` (the block-capture receiver),
1986        // line 2: "(hushed)" — matches the shape of the real regression
1987        // (`tests/tier1-native/conventions-screenplay-preset/story.brink`).
1988        let (program, line_tables) = program_with_line_table(vec![
1989            plain_entry("VENDOR"),
1990            one_slot_template_entry(),
1991            plain_entry("(hushed)"),
1992        ]);
1993        // The captured block was empty: a real, present `Fragment` with no
1994        // parts — not an omitted line-table entry (issue #2091's own "what
1995        // happens to the line-table entry" question: present-but-empty).
1996        let fragments = vec![Fragment {
1997            parts: vec![],
1998            tags: vec![],
1999        }];
2000
2001        let parts = vec![
2002            line_ref(0, vec![], brink_format::LineFlags::from_plain("VENDOR")),
2003            OutputPart::Newline,
2004            line_ref(
2005                1,
2006                vec![Value::FragmentRef(0)],
2007                brink_format::LineFlags::empty(),
2008            ),
2009            OutputPart::Newline,
2010            line_ref(2, vec![], brink_format::LineFlags::from_plain("(hushed)")),
2011        ];
2012
2013        // Element-attachment data (issue #2108) is dropped here — these
2014        // pre-existing fixtures don't exercise attach conventions.
2015        let lines: Vec<(String, Vec<String>)> =
2016            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2017                .into_iter()
2018                .map(|(text, tags, _element)| (text, tags))
2019                .collect();
2020        assert_eq!(
2021            lines,
2022            vec![
2023                ("VENDOR".to_string(), Vec::<String>::new()),
2024                ("(hushed)".to_string(), Vec::<String>::new()),
2025            ],
2026            "an empty content/Fragment capture must not render its own blank \
2027             line between real content: {lines:?}"
2028        );
2029    }
2030
2031    /// Reviewer finding (PR #2140, issue #2091): the scope is NOT limited to
2032    /// issue #1839's `block`-capture receiver. `part_involves_fragment_ref`
2033    /// keys on `Value::FragmentRef` alone, and `brink-codegen-inkb::content::
2034    /// emit_slot_expr`'s `BeginFragment`…`EndFragment` composition pattern
2035    /// wraps *every* template slot whose expr is a function call
2036    /// (`lir::Expr::is_function_call()`), in ordinary display position, in
2037    /// both dialects — not just a `block` receiver. This pins that broader,
2038    /// actual scope directly: a line whose only content is a call like
2039    /// `{ f() }`, where `f` emits no side-effect text and returns an empty
2040    /// value, is suppressed by the exact same mechanism as the block-capture
2041    /// case above, with no `block`-capture machinery involved at all.
2042    #[test]
2043    fn resolve_lines_suppresses_a_blank_line_from_an_empty_display_position_call_composition() {
2044        // line 0: "Before.", line 1: `{f()}` (ordinary call composition —
2045        // NOT a `block`-capture receiver), line 2: "After."
2046        let (program, line_tables) = program_with_line_table(vec![
2047            plain_entry("Before."),
2048            one_slot_template_entry(),
2049            plain_entry("After."),
2050        ]);
2051        // Models `emit_slot_expr`'s composition pattern for `{ f() }`
2052        // where `f` produced no side-effect output and its return value
2053        // stringified to empty — a real, present `Fragment` with no parts,
2054        // exactly as a `block` capture's empty fragment looks structurally.
2055        let fragments = vec![Fragment {
2056            parts: vec![],
2057            tags: vec![],
2058        }];
2059
2060        let parts = vec![
2061            line_ref(0, vec![], brink_format::LineFlags::from_plain("Before.")),
2062            OutputPart::Newline,
2063            line_ref(
2064                1,
2065                vec![Value::FragmentRef(0)],
2066                brink_format::LineFlags::empty(),
2067            ),
2068            OutputPart::Newline,
2069            line_ref(2, vec![], brink_format::LineFlags::from_plain("After.")),
2070        ];
2071
2072        // Element-attachment data (issue #2108) is dropped here — these
2073        // pre-existing fixtures don't exercise attach conventions.
2074        let lines: Vec<(String, Vec<String>)> =
2075            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2076                .into_iter()
2077                .map(|(text, tags, _element)| (text, tags))
2078                .collect();
2079        assert_eq!(
2080            lines,
2081            vec![
2082                ("Before.".to_string(), Vec::<String>::new()),
2083                ("After.".to_string(), Vec::<String>::new()),
2084            ],
2085            "an empty display-position call-composition FragmentRef must be \
2086             suppressed identically to a block capture — this is the \
2087             broader scope the discriminator actually covers, not just \
2088             #1839's block-capture receiver: {lines:?}"
2089        );
2090    }
2091
2092    /// Scope boundary: this fix is specifically about `content`/Fragment
2093    /// captures, not "any interpolation that happens to render empty". A
2094    /// `Slot` bound to a plain, non-`FragmentRef` value that resolves empty
2095    /// keeps its pre-existing blank beat — unchanged, matching the
2096    /// deliberately-preserved `inline-markup-point-marker` fixture (a
2097    /// self-closing markup span with no children, issue #1716).
2098    #[test]
2099    fn resolve_lines_does_not_suppress_a_blank_line_from_a_non_fragment_empty_slot() {
2100        let (program, line_tables) = program_with_line_table(vec![
2101            plain_entry("VENDOR"),
2102            one_slot_template_entry(),
2103            plain_entry("(hushed)"),
2104        ]);
2105        let fragments: Vec<Fragment> = vec![];
2106
2107        let parts = vec![
2108            line_ref(0, vec![], brink_format::LineFlags::from_plain("VENDOR")),
2109            OutputPart::Newline,
2110            line_ref(1, vec![Value::Null], brink_format::LineFlags::empty()),
2111            OutputPart::Newline,
2112            line_ref(2, vec![], brink_format::LineFlags::from_plain("(hushed)")),
2113        ];
2114
2115        // Element-attachment data (issue #2108) is dropped here — these
2116        // pre-existing fixtures don't exercise attach conventions.
2117        let lines: Vec<(String, Vec<String>)> =
2118            resolve_lines(&parts, &program, &line_tables, None, &fragments)
2119                .into_iter()
2120                .map(|(text, tags, _element)| (text, tags))
2121                .collect();
2122        assert_eq!(
2123            lines,
2124            vec![
2125                ("VENDOR".to_string(), Vec::<String>::new()),
2126                (String::new(), Vec::<String>::new()),
2127                ("(hushed)".to_string(), Vec::<String>::new()),
2128            ],
2129            "a non-Fragment empty slot must keep rendering its blank line: {lines:?}"
2130        );
2131    }
2132
2133    /// Streaming-API regression (the actual bug shape): `take_first_line`
2134    /// must skip the suppressed blank line silently — never handing it back
2135    /// as its own `Line::Text` — while still returning "VENDOR" and
2136    /// "(hushed)" as two separate completed lines, in order, with the
2137    /// cursor correctly advanced (no stall on the suppressed segment).
2138    #[test]
2139    fn take_first_line_skips_a_suppressed_line_and_returns_the_next_real_line() {
2140        let (program, line_tables) = program_with_line_table(vec![
2141            plain_entry("VENDOR"),
2142            one_slot_template_entry(),
2143            plain_entry("(hushed)"),
2144        ]);
2145
2146        let mut buf = OutputBuffer::new();
2147        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2148        buf.push_newline();
2149        buf.begin_fragment();
2150        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2151        buf.push_line_ref(
2152            0,
2153            1,
2154            vec![Value::FragmentRef(frag_idx)],
2155            brink_format::LineFlags::empty(),
2156        );
2157        buf.push_newline();
2158        buf.push_line_ref(
2159            0,
2160            2,
2161            vec![],
2162            brink_format::LineFlags::from_plain("(hushed)"),
2163        );
2164        buf.push_newline();
2165
2166        let mut got = Vec::new();
2167        // Bounded loop (VM-test hygiene): at most 3 real lines are possible
2168        // here, so 5 iterations is generous headroom against a stall.
2169        for _ in 0..5 {
2170            match buf.take_first_line(&program, &line_tables, None) {
2171                Some((text, _, _)) => got.push(text),
2172                None => break,
2173            }
2174        }
2175
2176        assert_eq!(
2177            got,
2178            vec!["VENDOR\n".to_string(), "(hushed)\n".to_string()],
2179            "the empty content capture must not surface as its own \
2180             (blank) streamed line: {got:?}"
2181        );
2182    }
2183
2184    /// Issue #2147 (gap 1 of #2091's follow-through review): `end_capture`
2185    /// -> `resolve_parts` is the string-capture path — the `EndStringEval`
2186    /// path an unrecognized choice display or `~ temp x = "..."` string-eval
2187    /// rides — and PR #2140 only fixed the line-oriented
2188    /// `resolve_lines`/`take_first_line` path. Same VENDOR / `{body}` /
2189    /// (hushed) shape as `resolve_lines_suppresses_a_blank_line_from_an_
2190    /// empty_content_capture`, but captured as a single string via
2191    /// `begin_capture`/`end_capture` instead of resolved line-by-line.
2192    #[test]
2193    fn end_capture_suppresses_a_blank_line_from_an_empty_content_capture() {
2194        let (program, line_tables) = program_with_line_table(vec![
2195            plain_entry("VENDOR"),
2196            one_slot_template_entry(),
2197            plain_entry("(hushed)"),
2198        ]);
2199
2200        let mut buf = OutputBuffer::new();
2201        // A real, present (empty) Fragment — same shape #1839's block
2202        // capture and #2140's display-position call composition produce.
2203        buf.begin_fragment();
2204        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2205
2206        buf.begin_capture();
2207        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2208        buf.push_newline();
2209        buf.push_line_ref(
2210            0,
2211            1,
2212            vec![Value::FragmentRef(frag_idx)],
2213            brink_format::LineFlags::empty(),
2214        );
2215        buf.push_newline();
2216        buf.push_line_ref(
2217            0,
2218            2,
2219            vec![],
2220            brink_format::LineFlags::from_plain("(hushed)"),
2221        );
2222
2223        let text = buf
2224            .end_capture(&program, &line_tables, None)
2225            .expect("checkpoint was just pushed");
2226        assert_eq!(
2227            text, "VENDOR\n(hushed)",
2228            "an empty content/Fragment capture inside a captured string \
2229             must not leave a stray blank line — must match resolve_lines' \
2230             suppression: {text:?}"
2231        );
2232    }
2233
2234    /// Scope boundary, mirrored from
2235    /// `resolve_lines_does_not_suppress_a_blank_line_from_a_non_fragment_
2236    /// empty_slot`: a `Slot` bound to a plain, non-`FragmentRef` value that
2237    /// resolves empty keeps its pre-existing blank line inside a captured
2238    /// string too — this fix is about `content`/Fragment captures
2239    /// specifically, not "any interpolation that happens to render empty".
2240    #[test]
2241    fn end_capture_does_not_suppress_a_blank_line_from_a_non_fragment_empty_slot() {
2242        let (program, line_tables) = program_with_line_table(vec![
2243            plain_entry("VENDOR"),
2244            one_slot_template_entry(),
2245            plain_entry("(hushed)"),
2246        ]);
2247
2248        let mut buf = OutputBuffer::new();
2249        buf.begin_capture();
2250        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2251        buf.push_newline();
2252        buf.push_line_ref(0, 1, vec![Value::Null], brink_format::LineFlags::empty());
2253        buf.push_newline();
2254        buf.push_line_ref(
2255            0,
2256            2,
2257            vec![],
2258            brink_format::LineFlags::from_plain("(hushed)"),
2259        );
2260
2261        let text = buf
2262            .end_capture(&program, &line_tables, None)
2263            .expect("checkpoint was just pushed");
2264        assert_eq!(
2265            text, "VENDOR\n\n(hushed)",
2266            "a non-Fragment empty slot must keep its blank line inside a \
2267             captured string: {text:?}"
2268        );
2269    }
2270
2271    /// Review finding on issue #2147's PR: `resolve_lines_annotated`
2272    /// deliberately suppresses its own final, unterminated entry (the
2273    /// `EXCEPTION (issue #2091)` block above it) — dropping the trailing
2274    /// newline along with it — while `resolve_parts`'s suppression only
2275    /// fired on an `OutputPart::Newline`. A captured string whose *last*
2276    /// segment (no trailing `Newline` part) is empty and Fragment-derived
2277    /// must drop that trailing newline too, matching `resolve_lines`.
2278    #[test]
2279    fn end_capture_drops_trailing_newline_before_an_unterminated_empty_fragment() {
2280        let (program, line_tables) =
2281            program_with_line_table(vec![plain_entry("a"), one_slot_template_entry()]);
2282
2283        let mut buf = OutputBuffer::new();
2284        // A real, present (empty) Fragment — same shape as the other
2285        // tests in this module.
2286        buf.begin_fragment();
2287        let frag_idx = buf.end_fragment().expect("checkpoint was just pushed");
2288
2289        buf.begin_capture();
2290        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("a"));
2291        buf.push_newline();
2292        // No trailing newline after this — the capture ends mid-line, same
2293        // as an unread transcript tail ending on an empty Fragment
2294        // interpolation.
2295        buf.push_line_ref(
2296            0,
2297            1,
2298            vec![Value::FragmentRef(frag_idx)],
2299            brink_format::LineFlags::empty(),
2300        );
2301
2302        let text = buf
2303            .end_capture(&program, &line_tables, None)
2304            .expect("checkpoint was just pushed");
2305        assert_eq!(
2306            text, "a",
2307            "an unterminated trailing empty Fragment interpolation must \
2308             drop its introducing newline too, matching resolve_lines' \
2309             final-entry suppression: {text:?}"
2310        );
2311    }
2312
2313    /// Review finding on issue #2147's PR: `resolve_parts`'s new
2314    /// suppression is reached not only from `end_capture`'s string-capture
2315    /// path but also from [`OutputBuffer::resolve_fragment`] — including
2316    /// when resolving a *nested* fragment's own interior, when that inner
2317    /// fragment's captured region spans more than one line and one of
2318    /// those interior lines is contributed purely by a further-nested,
2319    /// rendered-empty fragment. Pin that this interior suppression fires
2320    /// identically to the top-level `resolve_lines`/`end_capture` case —
2321    /// this is the "nested/multi-line fragment interior" effect the
2322    /// doc comment on `resolve_parts` discloses.
2323    #[test]
2324    fn resolve_fragment_suppresses_a_blank_line_from_a_nested_empty_fragment_interior() {
2325        let (program, line_tables) = program_with_line_table(vec![
2326            plain_entry("VENDOR"),
2327            one_slot_template_entry(),
2328            plain_entry("(hushed)"),
2329        ]);
2330
2331        let mut buf = OutputBuffer::new();
2332
2333        // The inner, empty Fragment (e.g. a block-capture receiver that
2334        // captured nothing).
2335        buf.begin_fragment();
2336        let inner_idx = buf.end_fragment().expect("checkpoint was just pushed");
2337
2338        // The outer Fragment: three lines, with the middle one contributed
2339        // purely by the (empty) inner Fragment — i.e. a multi-line
2340        // fragment whose own interior has a suppressible blank line.
2341        buf.begin_fragment();
2342        buf.push_line_ref(0, 0, vec![], brink_format::LineFlags::from_plain("VENDOR"));
2343        buf.push_newline();
2344        buf.push_line_ref(
2345            0,
2346            1,
2347            vec![Value::FragmentRef(inner_idx)],
2348            brink_format::LineFlags::empty(),
2349        );
2350        buf.push_newline();
2351        buf.push_line_ref(
2352            0,
2353            2,
2354            vec![],
2355            brink_format::LineFlags::from_plain("(hushed)"),
2356        );
2357        let outer_idx = buf.end_fragment().expect("checkpoint was just pushed");
2358
2359        let text = buf.resolve_fragment(outer_idx, &program, &line_tables, None);
2360        assert_eq!(
2361            text, "VENDOR\n(hushed)",
2362            "a multi-line fragment's own interior must suppress a blank \
2363             line from a nested, rendered-empty fragment the same way the \
2364             top-level resolve_lines/end_capture paths do: {text:?}"
2365        );
2366    }
2367
2368    /// Review finding on issue #2108's PR: unlike [`OutputBuffer::
2369    /// take_first_line`], [`OutputBuffer::flush_lines`] seeded
2370    /// `pending_element` from `self.pending_element` but never wrote the
2371    /// end-of-slice state back — so an `ElementAttachEnd` consumed by a
2372    /// `flush_lines` call was lost and the attach data stayed live forever
2373    /// on whatever the buffer resolved next. Drain an attach run's first
2374    /// line through `take_first_line` (the call that seeds
2375    /// `pending_element` in the first place) and its remainder — including
2376    /// the closing `ElementAttachEnd` — through `flush_lines` in one shot,
2377    /// then prove a line pushed afterward does NOT inherit the closed
2378    /// run's data.
2379    #[test]
2380    fn flush_lines_writes_back_pending_element_past_the_closed_run() {
2381        let p = test_dummy_program();
2382        let mut buf = OutputBuffer::new();
2383
2384        buf.push_element_attach("speaker".to_string(), "VENDOR".to_string());
2385        buf.push_text("Line one.");
2386        buf.push_newline();
2387        buf.push_text("Line two.");
2388        buf.push_newline();
2389        buf.push_element_attach_end();
2390
2391        let (first_text, _, first_element) = buf
2392            .take_first_line(&p, &[], None)
2393            .expect("first line of the attach run");
2394        assert_eq!(first_text, "Line one.\n");
2395        assert_eq!(
2396            first_element.get("speaker").map(String::as_str),
2397            Some("VENDOR")
2398        );
2399
2400        let rest = buf.flush_lines(&p, &[], None);
2401        let line_two = rest
2402            .iter()
2403            .find(|(text, ..)| text == "Line two.")
2404            .expect("Line two. present in the flush");
2405        assert_eq!(
2406            line_two.2.get("speaker").map(String::as_str),
2407            Some("VENDOR"),
2408            "the last line of the run itself must still carry the attach data: {rest:?}"
2409        );
2410
2411        // Pushed after the run closed — must not inherit "speaker": "VENDOR".
2412        buf.push_text("Unattached.");
2413        buf.push_newline();
2414        let (after_text, _, after_element) = buf
2415            .take_first_line(&p, &[], None)
2416            .expect("line after the closed run");
2417        assert_eq!(after_text, "Unattached.\n");
2418        assert!(
2419            after_element.is_empty(),
2420            "flush_lines must write pending_element back to empty once it \
2421             consumes the run-closing ElementAttachEnd: {after_element:?}"
2422        );
2423    }
2424
2425    /// Review finding on issue #2108's PR: [`OutputBuffer::reset_cursor`]
2426    /// rewound `self.cursor` but left `pending_element` populated. At index
2427    /// 0 no attach run has accumulated yet, so a locale hot-swap re-render
2428    /// (the public use of `reset_cursor`) leaked the previous drain pass's
2429    /// element data onto the re-drained leading line.
2430    ///
2431    /// Transcript: `[narration, NL, ElementAttach(speaker=VENDOR), dialogue,
2432    /// NL]` — the exact probe from the finding. The narration line reports
2433    /// `{}` on the first pass (the attach hasn't happened yet); after
2434    /// draining the whole buffer once and calling `reset_cursor`, the
2435    /// re-drained narration line must report `{}` again too, not the
2436    /// dialogue run's `speaker` leaking backward from the previous pass.
2437    #[test]
2438    fn reset_cursor_clears_pending_element() {
2439        let p = test_dummy_program();
2440        let mut buf = OutputBuffer::new();
2441
2442        buf.push_text("Intro.");
2443        buf.push_newline();
2444        buf.push_element_attach("speaker".to_string(), "VENDOR".to_string());
2445        buf.push_text("Dialogue.");
2446        buf.push_newline();
2447
2448        let (first_text, _, first_element) =
2449            buf.take_first_line(&p, &[], None).expect("narration line");
2450        assert_eq!(first_text, "Intro.\n");
2451        assert!(first_element.is_empty(), "{first_element:?}");
2452
2453        let (second_text, _, second_element) =
2454            buf.take_first_line(&p, &[], None).expect("dialogue line");
2455        assert_eq!(second_text, "Dialogue.\n");
2456        assert_eq!(
2457            second_element.get("speaker").map(String::as_str),
2458            Some("VENDOR")
2459        );
2460
2461        buf.reset_cursor();
2462        let (text_after_reset, _, element_after_reset) = buf
2463            .take_first_line(&p, &[], None)
2464            .expect("re-drained narration line after reset_cursor");
2465        assert_eq!(text_after_reset, "Intro.\n");
2466        assert!(
2467            element_after_reset.is_empty(),
2468            "reset_cursor must clear pending_element — no attach run has \
2469             accumulated yet at index 0, so the re-drained leading line \
2470             must not inherit the previous pass's speaker: \
2471             {element_after_reset:?}"
2472        );
2473    }
2474}