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