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}