Skip to main content

brink_runtime/
output.rs

1//! Output buffer with glue handling and deferred line resolution.
2
3use brink_format::{
4    LineContent, LineEntry, LinePart, PluralCategory, PluralResolver, SelectKey, Value,
5};
6
7use crate::program::Program;
8use crate::value_ops;
9
10/// A part of accumulated output.
11///
12/// Output parts are structural references that resolve at read time against
13/// the current line tables and plural resolver. This enables locale-hot-swap:
14/// the same transcript can be re-rendered in different languages without
15/// re-executing the story.
16#[derive(Debug, Clone)]
17pub enum OutputPart {
18    /// Eagerly-resolved text. Not produced by the VM in production —
19    /// used in tests and available for external transcript construction.
20    Text(String),
21    /// Deferred line reference — resolved at read time against the
22    /// current line tables and plural resolver.
23    LineRef {
24        container_idx: u32,
25        line_idx: u16,
26        slots: Vec<Value>,
27        flags: brink_format::LineFlags,
28    },
29    /// Deferred value — stringified at read time.
30    ValueRef(Value),
31    Newline,
32    /// Word break — renders as a single space between content parts.
33    Spring,
34    Glue,
35    /// Marks the start of a captured region (string eval, tag, or function call).
36    Checkpoint,
37    /// A tag associated with the current line of output.
38    Tag(String),
39}
40
41impl OutputPart {
42    /// Resolve this output part to its text representation.
43    ///
44    /// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
45    /// using the provided program, line tables, and plural resolver.
46    /// Structural parts (`Newline`, `Spring`, `Glue`, `Checkpoint`, `Tag`)
47    /// resolve to empty string — they are handled by the resolution pipeline.
48    pub fn resolve(
49        &self,
50        program: &Program,
51        line_tables: &[Vec<LineEntry>],
52        resolver: Option<&dyn PluralResolver>,
53    ) -> String {
54        resolve_part(self, program, line_tables, resolver, &[])
55    }
56
57    /// Returns true if this part represents non-whitespace text content.
58    fn is_content(&self) -> bool {
59        match self {
60            Self::Text(s) => !s.trim().is_empty(),
61            Self::LineRef { flags, .. } => {
62                !flags.contains(brink_format::LineFlags::ALL_WS)
63                    && !flags.contains(brink_format::LineFlags::EMPTY)
64            }
65            Self::ValueRef(_) => true,
66            _ => false,
67        }
68    }
69}
70
71/// Resolve a single output part to its text representation.
72///
73/// `Text` parts pass through. `LineRef` and `ValueRef` are resolved
74/// using the provided program, line tables, and plural resolver.
75fn resolve_part(
76    part: &OutputPart,
77    program: &Program,
78    line_tables: &[Vec<LineEntry>],
79    resolver: Option<&dyn PluralResolver>,
80    fragments: &[Fragment],
81) -> String {
82    match part {
83        OutputPart::Text(s) => s.clone(),
84        OutputPart::LineRef {
85            container_idx,
86            line_idx,
87            slots,
88            ..
89        } => resolve_line_ref(
90            program,
91            line_tables,
92            *container_idx,
93            *line_idx,
94            slots,
95            resolver,
96            fragments,
97        ),
98        OutputPart::ValueRef(Value::FragmentRef(idx)) => {
99            // Resolve the fragment's parts against current line tables.
100            let idx = *idx as usize;
101            if let Some(frag) = fragments.get(idx) {
102                resolve_parts(&frag.parts, program, line_tables, resolver, fragments)
103            } else {
104                String::new()
105            }
106        }
107        OutputPart::ValueRef(val) => value_ops::stringify(val, program),
108        OutputPart::Newline
109        | OutputPart::Spring
110        | OutputPart::Glue
111        | OutputPart::Checkpoint
112        | OutputPart::Tag(_) => String::new(),
113    }
114}
115
116/// Resolve a `LineRef` to its text content.
117fn resolve_line_ref(
118    program: &Program,
119    line_tables: &[Vec<LineEntry>],
120    container_idx: u32,
121    line_idx: u16,
122    slots: &[Value],
123    resolver: Option<&dyn PluralResolver>,
124    fragments: &[Fragment],
125) -> String {
126    let scope_idx = program.scope_table_idx(container_idx) as usize;
127    let lines = &line_tables[scope_idx];
128    let Some(entry) = lines.get(line_idx as usize) else {
129        return String::new();
130    };
131
132    match &entry.content {
133        LineContent::Plain(s) => s.clone(),
134        LineContent::Template(parts) => {
135            let mut result = String::new();
136            for part in parts {
137                let owned;
138                let fragment: &str = match part {
139                    LinePart::Literal(s) => s.as_str(),
140                    LinePart::Slot(n) => {
141                        owned = slots
142                            .get(*n as usize)
143                            .map(|v| match v {
144                                Value::FragmentRef(idx) => {
145                                    let idx = *idx as usize;
146                                    fragments.get(idx).map_or_else(String::new, |frag| {
147                                        resolve_parts(
148                                            &frag.parts,
149                                            program,
150                                            line_tables,
151                                            resolver,
152                                            fragments,
153                                        )
154                                    })
155                                }
156                                other => value_ops::stringify(other, program),
157                            })
158                            .unwrap_or_default();
159                        owned.as_str()
160                    }
161                    LinePart::Select {
162                        slot,
163                        variants,
164                        default,
165                    } => {
166                        owned =
167                            resolve_select(*slot, variants, default, slots, resolver).to_string();
168                        owned.as_str()
169                    }
170                };
171                // Skip empty fragments (null/empty slots) and collapse
172                // whitespace at join points when empty slots produce
173                // adjacent spaces or leading whitespace.
174                if fragment.is_empty() {
175                    continue;
176                }
177                if (result.is_empty() || result.ends_with(' ')) && fragment.starts_with(' ') {
178                    result.push_str(fragment.trim_start());
179                } else {
180                    result.push_str(fragment);
181                }
182            }
183            result
184        }
185    }
186}
187
188/// Resolve a Select part against its slot value.
189///
190/// Cascade: Exact → Keyword → Cardinal/Ordinal → default.
191fn resolve_select<'a>(
192    slot: u8,
193    variants: &'a [(SelectKey, String)],
194    default: &'a str,
195    slots: &[Value],
196    resolver: Option<&dyn PluralResolver>,
197) -> &'a str {
198    let Some(val) = slots.get(slot as usize) else {
199        return default;
200    };
201
202    #[expect(clippy::cast_possible_truncation)]
203    let n: Option<i64> = match val {
204        Value::Int(i) => Some(i64::from(*i)),
205        Value::Float(f) => Some(*f as i64),
206        _ => None,
207    };
208
209    // Exact match.
210    if let Some(n) = n {
211        #[expect(clippy::cast_possible_truncation)]
212        let n32 = n as i32;
213        for (key, text) in variants {
214            if let SelectKey::Exact(e) = key
215                && *e == n32
216            {
217                return text;
218            }
219        }
220    }
221
222    // Keyword match.
223    if let Value::String(s) = val {
224        for (key, text) in variants {
225            if let SelectKey::Keyword(k) = key
226                && k == s.as_ref()
227            {
228                return text;
229            }
230        }
231    }
232
233    // Plural resolution.
234    if let (Some(n), Some(r)) = (n, resolver) {
235        let cardinal: PluralCategory = r.cardinal(n, None);
236        for (key, text) in variants {
237            if let SelectKey::Cardinal(cat) = key
238                && *cat == cardinal
239            {
240                return text;
241            }
242        }
243        let ordinal: PluralCategory = r.ordinal(n);
244        for (key, text) in variants {
245            if let SelectKey::Ordinal(cat) = key
246                && *cat == ordinal
247            {
248                return text;
249            }
250        }
251    }
252
253    default
254}
255
256/// A finalized fragment — structural output parts plus any associated tags.
257#[derive(Debug, Clone)]
258pub struct Fragment {
259    pub parts: Vec<OutputPart>,
260    pub tags: Vec<String>,
261}
262
263/// Accumulates output text with glue resolution.
264///
265/// The buffer is split into two storage areas:
266/// - **transcript**: append-only log of all output parts. Never drained.
267///   A read cursor advances on `take_first_line`/`flush_lines`.
268/// - **capture**: transient scratch space for string eval, tag collection,
269///   and function return value capture. Drained by `end_capture`.
270#[derive(Debug, Clone)]
271pub(crate) struct OutputBuffer {
272    /// Append-only output log. Parts are never removed.
273    pub(crate) transcript: Vec<OutputPart>,
274    /// Read cursor into transcript. Advances on take/flush.
275    pub(crate) cursor: usize,
276    /// Transient capture scratch space.
277    capture: Vec<OutputPart>,
278    /// Nesting depth of active captures. When > 0, pushes route to `capture`.
279    capture_depth: usize,
280    /// Finalized fragments — structural output parts for locale re-rendering.
281    fragments: Vec<Fragment>,
282    /// Current fragment being captured.
283    fragment_capture: Vec<OutputPart>,
284    /// Fragment capture nesting depth. When > 0, pushes route to `fragment_capture`.
285    fragment_depth: usize,
286    /// Tags accumulated during each nested fragment capture level.
287    fragment_pending_tags: Vec<Vec<String>>,
288}
289
290impl OutputBuffer {
291    pub fn new() -> Self {
292        Self {
293            transcript: Vec::new(),
294            cursor: 0,
295            capture: Vec::new(),
296            capture_depth: 0,
297            fragments: Vec::new(),
298            fragment_capture: Vec::new(),
299            fragment_depth: 0,
300            fragment_pending_tags: Vec::new(),
301        }
302    }
303
304    /// Returns the active push target.
305    /// Priority: capture (eagerly resolves) > fragment (structural) > transcript.
306    fn target(&mut self) -> &mut Vec<OutputPart> {
307        if self.capture_depth > 0 {
308            &mut self.capture
309        } else if self.fragment_depth > 0 {
310            &mut self.fragment_capture
311        } else {
312            &mut self.transcript
313        }
314    }
315
316    /// Length of the active push target. Used to record function output
317    /// start points for trailing whitespace trim on return.
318    pub(crate) fn target_len(&self) -> usize {
319        if self.capture_depth > 0 {
320            self.capture.len()
321        } else if self.fragment_depth > 0 {
322            self.fragment_capture.len()
323        } else {
324            self.transcript.len()
325        }
326    }
327
328    /// Trim trailing whitespace from the active output target, walking
329    /// backward to `start`. Matches the C# runtime's
330    /// `TrimWhitespaceFromFunctionEnd`: on function return, remove
331    /// trailing `Newline`, `Spring`, and whitespace-only text so that
332    /// function output doesn't inject unwanted line breaks.
333    pub(crate) fn trim_function_end(&mut self, start: usize) {
334        let target = self.target();
335        while target.len() > start {
336            match target.last() {
337                Some(OutputPart::Newline | OutputPart::Spring) => {
338                    target.pop();
339                }
340                Some(OutputPart::Text(s)) if s.trim().is_empty() => {
341                    target.pop();
342                }
343                Some(OutputPart::LineRef { flags, .. })
344                    if flags.contains(brink_format::LineFlags::ALL_WS) =>
345                {
346                    target.pop();
347                }
348                _ => break,
349            }
350        }
351    }
352
353    /// No longer called by the VM — candidate for removal.
354    #[cfg(test)]
355    pub fn push_text(&mut self, text: &str) {
356        if text.is_empty() {
357            return;
358        }
359        // Suppress whitespace-only text when there's no content yet,
360        // matching the C# ink runtime's output stream filtering.
361        // This handles leading spaces after choice selection (`"^ "`).
362        if !self.has_content() && text.trim().is_empty() {
363            return;
364        }
365        // Collapse adjacent whitespace at text boundaries: if the
366        // previous text part ends with whitespace and this text starts
367        // with whitespace, trim the leading whitespace from this text.
368        let text = if text.starts_with(char::is_whitespace) && self.ends_in_whitespace() {
369            text.trim_start()
370        } else {
371            text
372        };
373        if !text.is_empty() {
374            self.target().push(OutputPart::Text(text.to_owned()));
375        }
376    }
377
378    pub fn push_newline(&mut self) {
379        // Suppress leading newlines (no content yet) and duplicate newlines,
380        // matching the C# ink runtime's output stream filtering.
381        //
382        // Inside a capture, use scope-local has_content().  Outside, check
383        // the unread transcript for content **or Spring** — Spring is brink's
384        // equivalent of the C# `"^ "` (space) that inklecate emits in choice
385        // targets.  In C#, that space is a StringValue which makes
386        // `outputStreamContainsContent` true, allowing the subsequent newline
387        // through.  Without counting Spring, post-choice newlines are lost.
388        let has_content = if self.capture_depth > 0 {
389            self.has_content()
390        } else {
391            self.unread_has_content_or_spring()
392        };
393        if !has_content || self.ends_in_newline() {
394            return;
395        }
396        self.target().push(OutputPart::Newline);
397    }
398
399    /// Returns true if the active target contains any text content.
400    /// When inside a capture, scans the capture vec (stopping at checkpoint).
401    /// When outside, scans the transcript from cursor position.
402    fn has_content(&self) -> bool {
403        if self.capture_depth > 0 {
404            self.capture
405                .iter()
406                .rev()
407                .take_while(|p| !matches!(p, OutputPart::Checkpoint))
408                .any(OutputPart::is_content)
409        } else {
410            self.transcript[self.cursor..]
411                .iter()
412                .rev()
413                .any(OutputPart::is_content)
414        }
415    }
416
417    /// Returns true if the unread transcript contains content or a Spring.
418    ///
419    /// This mirrors the C# runtime's `outputStreamContainsContent` check,
420    /// which returns true for ANY `StringValue` in the output stream.  In C#,
421    /// the choice target's `"^ "` (a space) is a `StringValue` — its brink
422    /// equivalent is `Spring`.  After `ResetOutput()` clears the stream at the
423    /// start of each `Continue()`, the choice target's space is the first thing
424    /// pushed, making `outputStreamContainsContent` true.  In brink, the
425    /// cursor advance at yield points has the same effect as `ResetOutput()`,
426    /// so checking unread parts mirrors the per-`Continue()` scope.
427    fn unread_has_content_or_spring(&self) -> bool {
428        self.transcript[self.cursor..]
429            .iter()
430            .any(|p| p.is_content() || matches!(p, OutputPart::Spring))
431    }
432
433    /// Returns true if the last part in the active target is a newline.
434    fn ends_in_newline(&self) -> bool {
435        let target = if self.capture_depth > 0 {
436            &self.capture
437        } else {
438            &self.transcript
439        };
440        matches!(target.last(), Some(OutputPart::Newline))
441    }
442
443    /// Returns true if the last part is text ending with whitespace.
444    /// Only checks the immediately preceding part — intervening Glue or
445    /// Newline parts mean the glue system handles the join instead.
446    #[cfg(test)]
447    fn ends_in_whitespace(&self) -> bool {
448        let target = if self.capture_depth > 0 {
449            &self.capture
450        } else {
451            &self.transcript
452        };
453        match target.last() {
454            Some(OutputPart::Text(s)) => s.ends_with(char::is_whitespace),
455            Some(OutputPart::LineRef { flags, .. }) => {
456                flags.contains(brink_format::LineFlags::ENDS_WITH_WS)
457            }
458            _ => false,
459        }
460    }
461
462    pub fn push_glue(&mut self) {
463        self.target().push(OutputPart::Glue);
464    }
465
466    /// Push a word break. Deduplicated: no consecutive Springs.
467    pub fn push_spring(&mut self) {
468        let target = self.target();
469        if !matches!(target.last(), Some(OutputPart::Spring)) {
470            target.push(OutputPart::Spring);
471        }
472    }
473
474    /// Push a deferred line reference. Resolved at read time.
475    /// Applies the same filtering as `push_text` using precomputed flags.
476    pub fn push_line_ref(
477        &mut self,
478        container_idx: u32,
479        line_idx: u16,
480        slots: Vec<Value>,
481        flags: brink_format::LineFlags,
482    ) {
483        // Suppress whitespace-only/empty content when there's no content yet.
484        if !self.has_content()
485            && (flags.contains(brink_format::LineFlags::ALL_WS)
486                || flags.contains(brink_format::LineFlags::EMPTY))
487        {
488            return;
489        }
490        self.target().push(OutputPart::LineRef {
491            container_idx,
492            line_idx,
493            slots,
494            flags,
495        });
496    }
497
498    /// Push a deferred value. Stringified at read time.
499    /// Null values are dropped (they stringify to empty string).
500    pub fn push_value_ref(&mut self, value: Value) {
501        if matches!(value, Value::Null) {
502            return;
503        }
504        // Suppress whitespace-only string values when there's no content yet.
505        if !self.has_content()
506            && let Value::String(ref s) = value
507            && s.trim().is_empty()
508        {
509            return;
510        }
511        self.target().push(OutputPart::ValueRef(value));
512    }
513
514    /// Push a tag associated with the current output line.
515    pub fn push_tag(&mut self, tag: String) {
516        self.target().push(OutputPart::Tag(tag));
517    }
518
519    /// Returns true if a capture is currently active.
520    pub fn has_checkpoint(&self) -> bool {
521        self.capture_depth > 0
522    }
523
524    /// Begin a capture. Pushes a checkpoint to the capture scratch space.
525    /// While a capture is active, all pushes route to the capture vec.
526    pub fn begin_capture(&mut self) {
527        self.capture_depth += 1;
528        self.capture.push(OutputPart::Checkpoint);
529    }
530
531    /// End the most recent capture: drain from the last checkpoint in the
532    /// capture vec, resolve glue, and return the result as a string.
533    ///
534    /// Returns `None` if there is no checkpoint.
535    pub fn end_capture(
536        &mut self,
537        program: &Program,
538        line_tables: &[Vec<LineEntry>],
539        resolver: Option<&dyn PluralResolver>,
540    ) -> Option<String> {
541        let cp_idx = self
542            .capture
543            .iter()
544            .rposition(|p| matches!(p, OutputPart::Checkpoint))?;
545
546        let captured: Vec<OutputPart> = self.capture.drain(cp_idx..).collect();
547        // Skip the checkpoint itself (first element).
548        let captured = &captured[1..];
549
550        self.capture_depth = self.capture_depth.saturating_sub(1);
551
552        Some(resolve_parts(
553            captured,
554            program,
555            line_tables,
556            resolver,
557            &self.fragments,
558        ))
559    }
560
561    // ── Fragment capture ───────────────────────────────────────────────
562
563    /// Begin capturing output into a new fragment.
564    pub fn begin_fragment(&mut self) {
565        self.fragment_depth += 1;
566        self.fragment_capture.push(OutputPart::Checkpoint);
567        self.fragment_pending_tags.push(Vec::new());
568    }
569
570    /// End the current fragment capture: drain from the last checkpoint,
571    /// store the parts in the fragment store, return the fragment index.
572    #[expect(clippy::cast_possible_truncation)]
573    pub fn end_fragment(&mut self) -> Option<u32> {
574        let cp_idx = self
575            .fragment_capture
576            .iter()
577            .rposition(|p| matches!(p, OutputPart::Checkpoint))?;
578
579        let captured: Vec<OutputPart> = self.fragment_capture.drain(cp_idx..).collect();
580        // Skip the checkpoint itself (first element).
581        let parts: Vec<OutputPart> = captured.into_iter().skip(1).collect();
582        let tags = self.fragment_pending_tags.pop().unwrap_or_default();
583        let idx = self.fragments.len() as u32;
584        self.fragments.push(Fragment { parts, tags });
585
586        self.fragment_depth = self.fragment_depth.saturating_sub(1);
587
588        Some(idx)
589    }
590
591    /// Returns true if currently inside a fragment capture.
592    pub fn in_fragment_capture(&self) -> bool {
593        self.fragment_depth > 0
594    }
595
596    /// Push a tag onto the current fragment being captured.
597    pub fn push_fragment_tag(&mut self, tag: String) {
598        if let Some(pending) = self.fragment_pending_tags.last_mut() {
599            pending.push(tag);
600        }
601    }
602
603    /// Read access to a finalized fragment's tags.
604    pub fn fragment_tags(&self, idx: u32) -> Option<&[String]> {
605        self.fragments.get(idx as usize).map(|f| f.tags.as_slice())
606    }
607
608    /// Read access to all finalized fragments.
609    pub fn fragments(&self) -> &[Fragment] {
610        &self.fragments
611    }
612
613    /// Read access to a finalized fragment's parts.
614    pub fn fragment(&self, idx: u32) -> Option<&[OutputPart]> {
615        self.fragments.get(idx as usize).map(|f| f.parts.as_slice())
616    }
617
618    /// Resolve a fragment's parts against the current line tables.
619    pub fn resolve_fragment(
620        &self,
621        idx: u32,
622        program: &Program,
623        line_tables: &[Vec<LineEntry>],
624        resolver: Option<&dyn PluralResolver>,
625    ) -> String {
626        match self.fragment(idx) {
627            Some(parts) => resolve_parts(parts, program, line_tables, resolver, &self.fragments),
628            None => String::new(),
629        }
630    }
631
632    /// Returns true if the buffer contains at least one complete line
633    /// (a Newline whose effect survived glue resolution, confirmed by
634    /// subsequent non-whitespace content).
635    ///
636    /// A Newline is "committed" when non-whitespace text appears after it
637    /// in the buffer — at that point, no future Glue can reach past the
638    /// text to eat the Newline.
639    pub(crate) fn has_completed_line(&self) -> bool {
640        if self.has_checkpoint() {
641            return false;
642        }
643        let unread = &self.transcript[self.cursor..];
644        if unread.is_empty() {
645            return false;
646        }
647
648        // Quick check: any newline at all?
649        if !unread.iter().any(|p| matches!(p, OutputPart::Newline)) {
650            return false;
651        }
652
653        // Run glue marking pass to determine which newlines survive.
654        let mut remove = vec![false; unread.len()];
655        mark_glue_removals(unread, &mut remove);
656
657        // Walk and find a committed newline: a surviving Newline (not removed,
658        // not in after_glue state) followed by non-whitespace-only text.
659        let mut after_glue = false;
660        let mut found_newline = false;
661
662        for (i, part) in unread.iter().enumerate() {
663            if remove[i] {
664                if matches!(part, OutputPart::Glue) {
665                    after_glue = true;
666                }
667                continue;
668            }
669            if part.is_content() {
670                if found_newline {
671                    return true;
672                }
673                after_glue = false;
674            } else {
675                match part {
676                    OutputPart::Newline if !after_glue => {
677                        found_newline = true;
678                    }
679                    OutputPart::Glue => {
680                        after_glue = true;
681                    }
682                    _ => {}
683                }
684            }
685        }
686
687        false
688    }
689
690    /// Drain the first complete line from the buffer, resolving glue
691    /// on the drained segment. Returns `(text, tags)`. The remainder
692    /// stays in the buffer for future calls.
693    ///
694    /// The returned text includes a trailing `\n` to indicate a complete
695    /// line. This matches the convention that `continue_maximally` joins
696    /// all single-line results with empty string to produce the same
697    /// output as the original `flush_lines` + `finalize_lines`.
698    ///
699    /// Returns `None` if there is no completed line.
700    pub(crate) fn take_first_line(
701        &mut self,
702        program: &Program,
703        line_tables: &[Vec<LineEntry>],
704        resolver: Option<&dyn PluralResolver>,
705    ) -> Option<(String, Vec<String>)> {
706        if self.has_checkpoint() {
707            return None;
708        }
709        let unread = &self.transcript[self.cursor..];
710        if unread.is_empty() {
711            return None;
712        }
713
714        let mut remove = vec![false; unread.len()];
715        mark_glue_removals(unread, &mut remove);
716
717        // Find the split point: the first surviving Newline (not removed,
718        // not in after_glue state) that has non-whitespace text after it.
719        let mut after_glue = false;
720        let mut candidate_newline: Option<usize> = None;
721
722        for (i, part) in unread.iter().enumerate() {
723            if remove[i] {
724                if matches!(part, OutputPart::Glue) {
725                    after_glue = true;
726                }
727                continue;
728            }
729            if part.is_content() {
730                if candidate_newline.is_some() {
731                    break;
732                }
733                after_glue = false;
734            } else {
735                match part {
736                    OutputPart::Newline if !after_glue => {
737                        candidate_newline = Some(i);
738                    }
739                    OutputPart::Glue => {
740                        after_glue = true;
741                    }
742                    _ => {}
743                }
744            }
745        }
746
747        let split_at = candidate_newline?;
748
749        // Resolve the slice through the newline (inclusive). No drain.
750        let slice = &self.transcript[self.cursor..=self.cursor + split_at];
751        let mut lines = resolve_lines(slice, program, line_tables, resolver, &self.fragments);
752        if lines.is_empty() {
753            return None;
754        }
755
756        // Advance cursor past the consumed newline.
757        self.cursor += split_at + 1;
758
759        let (mut text, tags) = lines.swap_remove(0);
760        text.push('\n');
761        Some((text, tags))
762    }
763
764    /// Resolve glue and flush to a string (ignoring tags).
765    ///
766    /// Glue removes the newline immediately before it and any leading
767    /// whitespace on the text immediately after it, stitching text together.
768    /// Resolve glue and flush to a string. Test-only — only works with
769    /// `Text`/`Newline`/`Glue` parts (no `LineRef`/`ValueRef`).
770    #[cfg(test)]
771    pub fn flush(&mut self) -> String {
772        debug_assert!(
773            !self.has_checkpoint(),
774            "flush() called with active checkpoints"
775        );
776        let unread = &self.transcript[self.cursor..];
777        let program = test_dummy_program();
778        let result = resolve_parts(unread, &program, &[], None, &self.fragments);
779        self.cursor = self.transcript.len();
780        result
781    }
782
783    /// Resolve glue and flush to structured per-line output.
784    ///
785    /// Each returned element is `(line_text, line_tags)`. Tags are associated
786    /// with the line they appear on in the output stream.
787    pub fn flush_lines(
788        &mut self,
789        program: &Program,
790        line_tables: &[Vec<LineEntry>],
791        resolver: Option<&dyn PluralResolver>,
792    ) -> Vec<(String, Vec<String>)> {
793        debug_assert!(
794            !self.has_checkpoint(),
795            "flush_lines() called with active checkpoints"
796        );
797        let unread = &self.transcript[self.cursor..];
798        let result = resolve_lines(unread, program, line_tables, resolver, &self.fragments);
799        self.cursor = self.transcript.len();
800        result
801    }
802
803    /// Returns true if there are unread parts in the transcript.
804    pub(crate) fn has_unread(&self) -> bool {
805        self.cursor < self.transcript.len()
806    }
807
808    /// Returns the full append-only transcript.
809    pub fn transcript(&self) -> &[OutputPart] {
810        &self.transcript
811    }
812
813    /// Reset the read cursor to the beginning for re-rendering.
814    pub fn reset_cursor(&mut self) {
815        self.cursor = 0;
816    }
817
818    /// Returns the number of parts in the transcript.
819    pub fn transcript_len(&self) -> usize {
820        self.transcript.len()
821    }
822}
823
824/// First pass of glue resolution: mark newlines and glue parts for removal.
825///
826/// For each `Glue` part, find the nearest preceding `Newline` (skipping
827/// whitespace-only text, tags, checkpoints, and already-removed parts)
828/// and mark both the newline and the glue for removal.
829fn mark_glue_removals(parts: &[OutputPart], remove: &mut [bool]) {
830    for (i, part) in parts.iter().enumerate() {
831        if matches!(part, OutputPart::Glue) {
832            for j in (0..i).rev() {
833                if remove[j] {
834                    continue;
835                }
836                match &parts[j] {
837                    OutputPart::Newline => {
838                        remove[j] = true;
839                        break;
840                    }
841                    OutputPart::Glue
842                    | OutputPart::Checkpoint
843                    | OutputPart::Tag(_)
844                    | OutputPart::Spring => {}
845                    OutputPart::Text(s) if s.trim().is_empty() => {}
846                    // Content (Text, LineRef, ValueRef) blocks glue scan.
847                    OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
848                        break;
849                    }
850                }
851            }
852            remove[i] = true;
853        }
854    }
855}
856
857/// Resolve glue in a slice of output parts and return the flattened string.
858fn resolve_parts(
859    parts: &[OutputPart],
860    program: &Program,
861    line_tables: &[Vec<LineEntry>],
862    resolver: Option<&dyn PluralResolver>,
863    fragments: &[Fragment],
864) -> String {
865    // First pass: mark newlines that should be removed by glue.
866    let mut remove = vec![false; parts.len()];
867    mark_glue_removals(parts, &mut remove);
868
869    let mut out = String::new();
870    let mut after_glue = false;
871
872    for (i, part) in parts.iter().enumerate() {
873        if remove[i] {
874            if matches!(part, OutputPart::Glue) {
875                after_glue = true;
876            }
877            continue;
878        }
879        match part {
880            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
881                let s = resolve_part(part, program, line_tables, resolver, fragments);
882                // Collapse adjacent whitespace at part boundaries.
883                let s = if s.starts_with(char::is_whitespace) && out.ends_with(char::is_whitespace)
884                {
885                    s.trim_start()
886                } else {
887                    &s
888                };
889                out.push_str(s);
890                if !s.trim().is_empty() {
891                    after_glue = false;
892                }
893            }
894            OutputPart::Spring => {
895                // Emit " " unless output is empty, ends in space, or ends in newline.
896                if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
897                    out.push(' ');
898                }
899            }
900            OutputPart::Newline => {
901                if !after_glue {
902                    let trimmed_len = out.trim_end_matches([' ', '\t']).len();
903                    out.truncate(trimmed_len);
904                    out.push('\n');
905                }
906            }
907            OutputPart::Glue | OutputPart::Checkpoint | OutputPart::Tag(_) => {
908                after_glue = true;
909            }
910        }
911    }
912
913    out
914}
915
916/// Resolve glue and split into per-line output with associated tags.
917///
918/// Each returned element is `(line_text, line_tags)`. Tags that appear
919/// in the stream associate with the current line (the line being built
920/// when the tag is encountered).
921pub(crate) fn resolve_lines(
922    parts: &[OutputPart],
923    program: &Program,
924    line_tables: &[Vec<LineEntry>],
925    resolver: Option<&dyn PluralResolver>,
926    fragments: &[Fragment],
927) -> Vec<(String, Vec<String>)> {
928    if parts.is_empty() {
929        return Vec::new();
930    }
931
932    // First pass: mark newlines/glue for removal (same logic as resolve_parts).
933    let mut remove = vec![false; parts.len()];
934    mark_glue_removals(parts, &mut remove);
935
936    let mut lines: Vec<(String, Vec<String>)> = Vec::new();
937    let mut current_text = String::new();
938    let mut current_tags: Vec<String> = Vec::new();
939    let mut after_glue = false;
940
941    for (i, part) in parts.iter().enumerate() {
942        if remove[i] {
943            if matches!(part, OutputPart::Glue) {
944                after_glue = true;
945            }
946            continue;
947        }
948        match part {
949            OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
950                let s = resolve_part(part, program, line_tables, resolver, fragments);
951                // Collapse adjacent whitespace at part boundaries.
952                let s = if s.starts_with(char::is_whitespace)
953                    && current_text.ends_with(char::is_whitespace)
954                {
955                    s.trim_start()
956                } else {
957                    &s
958                };
959                current_text.push_str(s);
960                if !s.trim().is_empty() {
961                    after_glue = false;
962                }
963            }
964            OutputPart::Spring => {
965                if !current_text.is_empty()
966                    && !current_text.ends_with(' ')
967                    && !current_text.ends_with('\n')
968                {
969                    current_text.push(' ');
970                }
971            }
972            OutputPart::Newline => {
973                if !after_glue {
974                    let trimmed = current_text.trim().to_string();
975                    lines.push((trimmed, std::mem::take(&mut current_tags)));
976                    current_text = String::new();
977                }
978            }
979            OutputPart::Tag(tag) => {
980                current_tags.push(tag.clone());
981            }
982            OutputPart::Glue | OutputPart::Checkpoint => {
983                after_glue = true;
984            }
985        }
986    }
987
988    // Always push the final line — even if empty — so that a trailing
989    // Newline part produces a trailing `\n` when the lines are joined.
990    let trimmed = current_text.trim().to_string();
991    lines.push((trimmed, current_tags));
992
993    lines
994}
995
996/// Create a minimal `Program` for tests that only use `Text`/`Newline`/`Glue`.
997#[cfg(test)]
998fn test_dummy_program() -> Program {
999    use std::collections::HashMap;
1000    Program {
1001        containers: vec![],
1002        address_map: HashMap::new(),
1003        scope_ids: vec![],
1004        source_checksum: 0,
1005        globals: vec![],
1006        global_map: HashMap::new(),
1007        name_table: vec![],
1008        address_by_path: HashMap::new(),
1009        root_idx: 0,
1010        list_literals: vec![],
1011        list_item_map: HashMap::new(),
1012        list_defs: vec![],
1013        list_def_map: HashMap::new(),
1014        external_fns: HashMap::new(),
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    /// Test helpers — `OutputBuffer` methods that need resolution context.
1023    /// Tests only use Text/Newline/Glue, so we pass an empty program.
1024    impl OutputBuffer {
1025        fn test_flush_lines(&mut self) -> Vec<(String, Vec<String>)> {
1026            let p = test_dummy_program();
1027            self.flush_lines(&p, &[], None)
1028        }
1029
1030        fn test_take_first_line(&mut self) -> Option<(String, Vec<String>)> {
1031            let p = test_dummy_program();
1032            self.take_first_line(&p, &[], None)
1033        }
1034
1035        fn test_end_capture(&mut self) -> Option<String> {
1036            let p = test_dummy_program();
1037            self.end_capture(&p, &[], None)
1038        }
1039    }
1040
1041    #[test]
1042    fn simple_text() {
1043        let mut buf = OutputBuffer::new();
1044        buf.push_text("hello");
1045        assert_eq!(buf.flush(), "hello");
1046    }
1047
1048    #[test]
1049    fn text_with_newline() {
1050        let mut buf = OutputBuffer::new();
1051        buf.push_text("hello");
1052        buf.push_newline();
1053        buf.push_text("world");
1054        assert_eq!(buf.flush(), "hello\nworld");
1055    }
1056
1057    #[test]
1058    fn glue_removes_newline() {
1059        let mut buf = OutputBuffer::new();
1060        buf.push_text("hello");
1061        buf.push_newline();
1062        buf.push_glue();
1063        buf.push_text("world");
1064        assert_eq!(buf.flush(), "helloworld");
1065    }
1066
1067    #[test]
1068    fn glue_preserves_leading_whitespace_in_text() {
1069        let mut buf = OutputBuffer::new();
1070        buf.push_text("hello");
1071        buf.push_newline();
1072        buf.push_glue();
1073        buf.push_text("  world");
1074        assert_eq!(buf.flush(), "hello  world");
1075    }
1076
1077    #[test]
1078    fn double_flush_is_empty() {
1079        let mut buf = OutputBuffer::new();
1080        buf.push_text("hello");
1081        let _ = buf.flush();
1082        assert_eq!(buf.flush(), "");
1083    }
1084
1085    #[test]
1086    fn leading_newline_suppressed() {
1087        let mut buf = OutputBuffer::new();
1088        buf.push_newline();
1089        buf.push_text("hello");
1090        assert_eq!(buf.flush(), "hello");
1091    }
1092
1093    /// Leading whitespace-only text at the start of output (no prior content)
1094    /// should be suppressed, just like leading newlines are suppressed.
1095    /// This happens after choice selection: choice bodies start with `"^ "`.
1096    #[test]
1097    fn leading_whitespace_only_text_suppressed() {
1098        let mut buf = OutputBuffer::new();
1099        buf.push_text(" ");
1100        buf.push_text("hello");
1101        assert_eq!(buf.flush(), "hello");
1102    }
1103
1104    /// Leading whitespace-only text after a flush should also be suppressed.
1105    /// Adjacent whitespace at text boundaries should collapse.
1106    /// E.g., start content "Hello " + inner content " right back" → "Hello right back".
1107    #[test]
1108    fn adjacent_whitespace_collapsed() {
1109        let mut buf = OutputBuffer::new();
1110        buf.push_text("Hello ");
1111        buf.push_text(" right back");
1112        assert_eq!(buf.flush(), "Hello right back");
1113    }
1114
1115    #[test]
1116    fn leading_whitespace_after_flush_suppressed() {
1117        let mut buf = OutputBuffer::new();
1118        buf.push_text("first");
1119        let _ = buf.flush();
1120        buf.push_text("  ");
1121        buf.push_text("second");
1122        assert_eq!(buf.flush(), "second");
1123    }
1124
1125    #[test]
1126    fn duplicate_newline_suppressed() {
1127        let mut buf = OutputBuffer::new();
1128        buf.push_text("hello");
1129        buf.push_newline();
1130        buf.push_newline();
1131        buf.push_text("world");
1132        assert_eq!(buf.flush(), "hello\nworld");
1133    }
1134
1135    #[test]
1136    fn leading_newline_after_flush_suppressed() {
1137        let mut buf = OutputBuffer::new();
1138        buf.push_text("first");
1139        let _ = buf.flush();
1140        // After flush, buffer is empty again — leading newline should be suppressed.
1141        buf.push_newline();
1142        buf.push_text("second");
1143        assert_eq!(buf.flush(), "second");
1144    }
1145
1146    #[test]
1147    fn begin_end_capture_basic() {
1148        let mut buf = OutputBuffer::new();
1149        buf.push_text("before");
1150        buf.begin_capture();
1151        buf.push_text("captured");
1152        let result = buf.test_end_capture();
1153        assert_eq!(result, Some("captured".to_owned()));
1154        assert_eq!(buf.flush(), "before");
1155    }
1156
1157    #[test]
1158    fn nested_captures() {
1159        let mut buf = OutputBuffer::new();
1160        buf.push_text("outer");
1161        buf.begin_capture();
1162        buf.push_text("middle");
1163        buf.begin_capture();
1164        buf.push_text("inner");
1165        let inner = buf.test_end_capture();
1166        assert_eq!(inner, Some("inner".to_owned()));
1167        let middle = buf.test_end_capture();
1168        assert_eq!(middle, Some("middle".to_owned()));
1169        assert_eq!(buf.flush(), "outer");
1170    }
1171
1172    #[test]
1173    fn capture_with_glue() {
1174        let mut buf = OutputBuffer::new();
1175        buf.begin_capture();
1176        buf.push_text("hello");
1177        buf.push_newline();
1178        buf.push_glue();
1179        buf.push_text(" world");
1180        let result = buf.test_end_capture();
1181        assert_eq!(result, Some("hello world".to_owned()));
1182    }
1183
1184    #[test]
1185    fn end_capture_no_checkpoint_returns_none() {
1186        let mut buf = OutputBuffer::new();
1187        buf.push_text("hello");
1188        assert_eq!(buf.test_end_capture(), None);
1189    }
1190
1191    #[test]
1192    fn has_content_respects_checkpoint() {
1193        let mut buf = OutputBuffer::new();
1194        buf.push_text("before");
1195        buf.begin_capture();
1196        // No content after the checkpoint.
1197        assert!(!buf.has_content());
1198        buf.push_text("after");
1199        assert!(buf.has_content());
1200    }
1201
1202    /// Glue should eat the following newline, not just the preceding one.
1203    /// Pattern: `<>-<>` where glue appears on both sides of the dash.
1204    #[test]
1205    fn glue_eats_following_newline() {
1206        let mut buf = OutputBuffer::new();
1207        buf.push_text("fifty");
1208        buf.push_newline();
1209        buf.push_glue();
1210        buf.push_text("-");
1211        buf.push_glue();
1212        buf.push_newline();
1213        buf.push_text("eight");
1214        assert_eq!(buf.flush(), "fifty-eight");
1215    }
1216
1217    /// Trailing whitespace before a newline should be trimmed.
1218    /// Pattern: `A {f():B}⏎X` where `f()` returns false — the space after
1219    /// "A" becomes trailing whitespace when the inline expression produces
1220    /// no output.
1221    #[test]
1222    fn trailing_whitespace_before_newline_trimmed() {
1223        let mut buf = OutputBuffer::new();
1224        buf.push_text("A ");
1225        buf.push_newline();
1226        buf.push_text("X");
1227        assert_eq!(buf.flush(), "A\nX");
1228    }
1229
1230    /// Glue should NOT trim leading whitespace from text content.
1231    /// Pattern: `Some <>⏎content<> with glue.`
1232    /// The space in " with glue." is content, not indentation.
1233    #[test]
1234    fn glue_preserves_text_whitespace() {
1235        let mut buf = OutputBuffer::new();
1236        buf.push_text("Some ");
1237        buf.push_glue();
1238        buf.push_newline();
1239        buf.push_text("content");
1240        buf.push_glue();
1241        buf.push_text(" with glue.");
1242        assert_eq!(buf.flush(), "Some content with glue.");
1243    }
1244
1245    /// Glue should skip past whitespace-only text to find the preceding newline.
1246    /// Pattern: `a\n" "<>b` — the `" "` is whitespace-only and should not block
1247    /// the glue from removing the newline.
1248    #[test]
1249    fn glue_skips_whitespace_only_text_to_find_newline() {
1250        let mut buf = OutputBuffer::new();
1251        buf.push_text("a");
1252        buf.push_newline();
1253        buf.push_text(" ");
1254        buf.push_glue();
1255        buf.push_text("b");
1256        assert_eq!(buf.flush(), "a b");
1257    }
1258
1259    // ── flush_lines tests ────────────────────────────────────────────
1260
1261    /// Tags should associate with the line they appear on.
1262    #[test]
1263    fn flush_lines_associates_tags_with_lines() {
1264        let mut buf = OutputBuffer::new();
1265        buf.push_text("line one");
1266        buf.push_newline();
1267        buf.push_text("line two");
1268        buf.push_tag("my_tag".to_string());
1269        buf.push_newline();
1270        buf.push_text("line three");
1271        let lines = buf.test_flush_lines();
1272        assert_eq!(lines.len(), 3);
1273        assert_eq!(lines[0].0, "line one");
1274        assert!(lines[0].1.is_empty());
1275        assert_eq!(lines[1].0, "line two");
1276        assert_eq!(lines[1].1, vec!["my_tag"]);
1277        assert_eq!(lines[2].0, "line three");
1278        assert!(lines[2].1.is_empty());
1279    }
1280
1281    /// Tags on the last line (no trailing newline) should still be captured.
1282    #[test]
1283    fn flush_lines_tag_on_last_line() {
1284        let mut buf = OutputBuffer::new();
1285        buf.push_text("only line");
1286        buf.push_tag("t".to_string());
1287        let lines = buf.test_flush_lines();
1288        assert_eq!(lines.len(), 1);
1289        assert_eq!(lines[0].0, "only line");
1290        assert_eq!(lines[0].1, vec!["t"]);
1291    }
1292
1293    /// `flush_lines` should resolve glue the same as `flush`.
1294    #[test]
1295    fn flush_lines_resolves_glue() {
1296        let mut buf = OutputBuffer::new();
1297        buf.push_text("hello");
1298        buf.push_newline();
1299        buf.push_glue();
1300        buf.push_text(" world");
1301        let lines = buf.test_flush_lines();
1302        assert_eq!(lines.len(), 1);
1303        assert_eq!(lines[0].0, "hello world");
1304    }
1305
1306    /// Flushing an empty buffer should return no lines.
1307    /// A spurious `[("", [])]` from an empty buffer causes leading `\n`
1308    /// when `step_with` calls `flush_lines` multiple times (e.g., before
1309    /// auto-selecting invisible default choices).
1310    #[test]
1311    fn flush_lines_empty_buffer_returns_no_lines() {
1312        let mut buf = OutputBuffer::new();
1313        let lines = buf.test_flush_lines();
1314        assert!(
1315            lines.is_empty(),
1316            "empty buffer should produce no lines, got: {lines:?}"
1317        );
1318    }
1319
1320    // ── has_completed_line / take_first_line tests ──────────────────
1321
1322    #[test]
1323    fn has_completed_line_empty() {
1324        let buf = OutputBuffer::new();
1325        assert!(!buf.has_completed_line());
1326    }
1327
1328    #[test]
1329    fn has_completed_line_text_only() {
1330        let mut buf = OutputBuffer::new();
1331        buf.push_text("hello");
1332        assert!(!buf.has_completed_line());
1333    }
1334
1335    #[test]
1336    fn has_completed_line_text_newline_only() {
1337        let mut buf = OutputBuffer::new();
1338        buf.push_text("hello");
1339        buf.push_newline();
1340        // No content after the newline → not committed.
1341        assert!(!buf.has_completed_line());
1342    }
1343
1344    #[test]
1345    fn has_completed_line_text_newline_text() {
1346        let mut buf = OutputBuffer::new();
1347        buf.push_text("hello");
1348        buf.push_newline();
1349        buf.push_text("world");
1350        assert!(buf.has_completed_line());
1351    }
1352
1353    #[test]
1354    fn has_completed_line_glue_eats_newline() {
1355        let mut buf = OutputBuffer::new();
1356        buf.push_text("hello");
1357        buf.push_newline();
1358        buf.push_glue();
1359        buf.push_text("world");
1360        // Glue eats the newline → no committed newline.
1361        assert!(!buf.has_completed_line());
1362    }
1363
1364    #[test]
1365    fn has_completed_line_during_capture() {
1366        let mut buf = OutputBuffer::new();
1367        buf.push_text("hello");
1368        buf.push_newline();
1369        buf.push_text("world");
1370        buf.begin_capture();
1371        // Active capture → not available for line extraction.
1372        assert!(!buf.has_completed_line());
1373    }
1374
1375    #[test]
1376    fn take_first_line_basic() {
1377        let mut buf = OutputBuffer::new();
1378        buf.push_text("hello");
1379        buf.push_newline();
1380        buf.push_text("world");
1381
1382        let result = buf.test_take_first_line();
1383        assert!(result.is_some());
1384        let (text, tags) = result.unwrap();
1385        assert_eq!(text, "hello\n");
1386        assert!(tags.is_empty());
1387
1388        // Remainder should produce "world" when flushed.
1389        assert_eq!(buf.flush(), "world");
1390    }
1391
1392    #[test]
1393    fn take_first_line_with_tags() {
1394        let mut buf = OutputBuffer::new();
1395        buf.push_text("tagged line");
1396        buf.push_tag("my_tag".to_string());
1397        buf.push_newline();
1398        buf.push_text("next line");
1399
1400        let (text, tags) = buf.test_take_first_line().unwrap();
1401        assert_eq!(text, "tagged line\n");
1402        assert_eq!(tags, vec!["my_tag"]);
1403
1404        assert_eq!(buf.flush(), "next line");
1405    }
1406
1407    #[test]
1408    fn take_first_line_multiple_lines() {
1409        let mut buf = OutputBuffer::new();
1410        buf.push_text("line one");
1411        buf.push_newline();
1412        buf.push_text("line two");
1413        buf.push_newline();
1414        buf.push_text("line three");
1415
1416        let (text1, _) = buf.test_take_first_line().unwrap();
1417        assert_eq!(text1, "line one\n");
1418
1419        let (text2, _) = buf.test_take_first_line().unwrap();
1420        assert_eq!(text2, "line two\n");
1421
1422        // Only "line three" remains, no newline after it → no completed line.
1423        assert!(!buf.has_completed_line());
1424        assert_eq!(buf.flush(), "line three");
1425    }
1426
1427    #[test]
1428    fn take_first_line_matches_flush_lines() {
1429        // Verify take_first_line produces the same first line as flush_lines.
1430        let parts = |buf: &mut OutputBuffer| {
1431            buf.push_text("A ");
1432            buf.push_tag("t1".to_string());
1433            buf.push_newline();
1434            buf.push_text("B");
1435            buf.push_newline();
1436            buf.push_text("C");
1437        };
1438
1439        let mut buf1 = OutputBuffer::new();
1440        parts(&mut buf1);
1441        let all_lines = buf1.test_flush_lines();
1442        let first_from_flush = &all_lines[0].0;
1443
1444        let mut buf2 = OutputBuffer::new();
1445        parts(&mut buf2);
1446        let (first_from_take, tags) = buf2.test_take_first_line().unwrap();
1447        // take_first_line appends \n; strip it for comparison.
1448        let first_trimmed = first_from_take.trim_end_matches('\n');
1449
1450        assert_eq!(first_trimmed, first_from_flush);
1451        assert_eq!(tags, all_lines[0].1);
1452    }
1453
1454    #[test]
1455    fn take_first_line_glue_preserves_subsequent() {
1456        // Glue eats the first newline; second newline survives.
1457        let mut buf = OutputBuffer::new();
1458        buf.push_text("hello");
1459        buf.push_newline();
1460        buf.push_glue();
1461        buf.push_text(" world");
1462        buf.push_newline();
1463        buf.push_text("next");
1464
1465        let (text, _) = buf.test_take_first_line().unwrap();
1466        assert_eq!(text, "hello world\n");
1467        assert_eq!(buf.flush(), "next");
1468    }
1469
1470    #[test]
1471    fn take_first_line_none_when_empty() {
1472        let mut buf = OutputBuffer::new();
1473        assert!(buf.test_take_first_line().is_none());
1474    }
1475
1476    #[test]
1477    fn take_first_line_none_when_no_newline() {
1478        let mut buf = OutputBuffer::new();
1479        buf.push_text("no newline");
1480        assert!(buf.test_take_first_line().is_none());
1481    }
1482
1483    // ── resolve_line_ref template collapsing tests ────────────────────
1484
1485    /// Build a minimal `Program` with one container (`scope_table_idx` = 0)
1486    /// and a line table with a single template entry, then resolve it.
1487    fn resolve_template(parts: Vec<LinePart>, slots: &[Value]) -> String {
1488        use crate::program::LinkedContainer;
1489        use brink_format::{CountingFlags, DefinitionId, DefinitionTag, LineEntry, LineFlags};
1490        use std::collections::HashMap;
1491
1492        let id = DefinitionId::new(DefinitionTag::Address, 0);
1493        let program = Program {
1494            containers: vec![LinkedContainer {
1495                id,
1496                bytecode: vec![],
1497                counting_flags: CountingFlags::empty(),
1498                path_hash: 0,
1499                scope_table_idx: 0,
1500            }],
1501            address_map: HashMap::new(),
1502            scope_ids: vec![id],
1503            source_checksum: 0,
1504            globals: vec![],
1505            global_map: HashMap::new(),
1506            name_table: vec![],
1507            address_by_path: HashMap::new(),
1508            root_idx: 0,
1509            list_literals: vec![],
1510            list_item_map: HashMap::new(),
1511            list_defs: vec![],
1512            list_def_map: HashMap::new(),
1513            external_fns: HashMap::new(),
1514        };
1515
1516        let line_tables = vec![vec![LineEntry {
1517            content: LineContent::Template(parts),
1518            source_hash: 0,
1519            flags: LineFlags::empty(),
1520            audio_ref: None,
1521            slot_info: vec![],
1522            source_location: None,
1523        }]];
1524
1525        resolve_line_ref(&program, &line_tables, 0, 0, slots, None, &[])
1526    }
1527
1528    #[test]
1529    fn template_collapses_double_space_from_empty_slot() {
1530        let result = resolve_template(
1531            vec![
1532                LinePart::Literal("Hello ".into()),
1533                LinePart::Slot(0),
1534                LinePart::Literal(" world".into()),
1535            ],
1536            &[Value::Null],
1537        );
1538        assert_eq!(result, "Hello world");
1539    }
1540
1541    #[test]
1542    fn template_preserves_spaces_with_nonempty_slot() {
1543        let result = resolve_template(
1544            vec![
1545                LinePart::Literal("Hello ".into()),
1546                LinePart::Slot(0),
1547                LinePart::Literal(" world".into()),
1548            ],
1549            &[Value::String("dear".into())],
1550        );
1551        assert_eq!(result, "Hello dear world");
1552    }
1553
1554    #[test]
1555    fn template_multiple_empty_slots_collapse() {
1556        let result = resolve_template(
1557            vec![
1558                LinePart::Literal("a ".into()),
1559                LinePart::Slot(0),
1560                LinePart::Literal(" ".into()),
1561                LinePart::Slot(1),
1562                LinePart::Literal(" b".into()),
1563            ],
1564            &[Value::Null, Value::Null],
1565        );
1566        assert_eq!(result, "a b");
1567    }
1568
1569    #[test]
1570    fn template_empty_string_slot_same_as_null() {
1571        let result = resolve_template(
1572            vec![
1573                LinePart::Literal("Hello ".into()),
1574                LinePart::Slot(0),
1575                LinePart::Literal(" world".into()),
1576            ],
1577            &[Value::String("".into())],
1578        );
1579        assert_eq!(result, "Hello world");
1580    }
1581}