Skip to main content

brink_runtime/output/
fragment.rs

1//! Fragment model for locale-safe slots.
2//!
3//! A fragment is a captured sub-region of output whose parts are stored
4//! structurally (not eagerly stringified), so it can be resolved against
5//! whatever line tables/locale are active at read time — the same
6//! locale-hot-swap property `OutputPart` documents at the module level.
7//! Fragments are how string-typed slot values in a template line (e.g.
8//! `"{~x}"` where `x` is itself templated) stay locale-safe rather than
9//! collapsing to a fixed-locale string at push time.
10
11use alloc::string::String;
12use alloc::vec::Vec;
13
14use brink_format::{LineEntry, PluralResolver};
15
16use super::{OutputBuffer, OutputPart, resolve_parts};
17use crate::program::Program;
18
19/// A finalized fragment — structural output parts plus any associated tags.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Fragment {
22    pub parts: Vec<OutputPart>,
23    pub tags: Vec<String>,
24}
25
26/// A borrowed view of one fragment inside a [`Fragments`] store.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct FragmentRef<'a> {
29    pub parts: &'a [OutputPart],
30    pub tags: &'a [String],
31}
32
33/// Where one fragment's parts live in the store's arena, plus its tags.
34#[derive(Debug, Clone, Default)]
35struct FragmentSpan {
36    start: u32,
37    end: u32,
38    tags: Vec<String>,
39}
40
41/// The fragment store: every finalized fragment's parts laid end to end in
42/// ONE arena, addressed by a per-fragment span.
43///
44/// Fragments are append-only and immutable once finalized (they exist so a
45/// choice's text or a computed substring can be re-rendered later, in
46/// another locale), so nothing ever needs to grow or drop one in place —
47/// which is what makes a shared arena safe. Before this, each fragment
48/// owned its own `Vec<OutputPart>`, and `end_fragment` built two of them
49/// (`drain().collect()` then `skip(1).collect()`) per capture: 182K of
50/// `hanoi-10`'s ~1M heap blocks were exactly those vectors. Now a capture
51/// moves its parts once, into the arena's spare capacity.
52///
53/// Indices are unchanged by the layout: fragment `i` is the `i`-th span,
54/// exactly as it was the `i`-th `Vec` — `Value::FragmentRef(i)` and the
55/// persisted `.brkt` numbering mean the same thing they always did.
56/// [`Fragment`] remains the materialized, owning form the codec and tests
57/// speak; [`Self::to_vec`] / `From<Vec<Fragment>>` convert.
58#[derive(Debug, Clone, Default)]
59pub struct Fragments {
60    arena: Vec<OutputPart>,
61    spans: Vec<FragmentSpan>,
62}
63
64impl Fragments {
65    /// Number of fragments in the store.
66    #[must_use]
67    pub fn len(&self) -> usize {
68        self.spans.len()
69    }
70
71    /// Whether the store holds no fragments.
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.spans.is_empty()
75    }
76
77    /// The parts of fragment `idx`, if it exists.
78    #[must_use]
79    pub fn parts(&self, idx: u32) -> Option<&[OutputPart]> {
80        let span = self.spans.get(idx as usize)?;
81        self.arena.get(span.start as usize..span.end as usize)
82    }
83
84    /// The tags of fragment `idx`, if it exists.
85    #[must_use]
86    pub fn tags(&self, idx: u32) -> Option<&[String]> {
87        self.spans.get(idx as usize).map(|s| s.tags.as_slice())
88    }
89
90    /// Fragment `idx` as a borrowed view, if it exists.
91    #[must_use]
92    pub fn get(&self, idx: u32) -> Option<FragmentRef<'_>> {
93        Some(FragmentRef {
94            parts: self.parts(idx)?,
95            tags: self.tags(idx)?,
96        })
97    }
98
99    /// Every fragment, in index order.
100    pub fn iter(&self) -> impl ExactSizeIterator<Item = FragmentRef<'_>> + '_ {
101        self.spans.iter().map(|span| FragmentRef {
102            parts: &self.arena[span.start as usize..span.end as usize],
103            tags: &span.tags,
104        })
105    }
106
107    /// The owning form, one `Fragment` per entry.
108    #[must_use]
109    pub fn to_vec(&self) -> Vec<Fragment> {
110        self.iter()
111            .map(|f| Fragment {
112                parts: f.parts.to_vec(),
113                tags: f.tags.to_vec(),
114            })
115            .collect()
116    }
117
118    /// Append a fragment whose parts are `parts`, returning its index.
119    #[expect(clippy::cast_possible_truncation)]
120    pub(crate) fn push(
121        &mut self,
122        parts: impl Iterator<Item = OutputPart>,
123        tags: Vec<String>,
124    ) -> u32 {
125        let start = self.arena.len() as u32;
126        self.arena.extend(parts);
127        let end = self.arena.len() as u32;
128        let idx = self.spans.len() as u32;
129        self.spans.push(FragmentSpan { start, end, tags });
130        idx
131    }
132}
133
134impl From<Vec<Fragment>> for Fragments {
135    fn from(fragments: Vec<Fragment>) -> Self {
136        let mut store = Self::default();
137        for Fragment { parts, tags } in fragments {
138            store.push(parts.into_iter(), tags);
139        }
140        store
141    }
142}
143
144impl OutputBuffer {
145    // ── Fragment capture ───────────────────────────────────────────────
146
147    /// Begin capturing output into a new fragment.
148    pub fn begin_fragment(&mut self) {
149        self.fragment_depth += 1;
150        self.fragment_capture.push(OutputPart::Checkpoint);
151        self.fragment_pending_tags.push(Vec::new());
152    }
153
154    /// End the current fragment capture: drain from the last checkpoint,
155    /// store the parts in the fragment store, return the fragment index.
156    pub fn end_fragment(&mut self) -> Option<u32> {
157        let cp_idx = self
158            .fragment_capture
159            .iter()
160            .rposition(|p| matches!(p, OutputPart::Checkpoint))?;
161
162        let tags = self.fragment_pending_tags.pop().unwrap_or_default();
163        // Move the captured parts (everything after the Checkpoint) straight
164        // into the arena, then drop the Checkpoint itself — no intermediate
165        // vector on either side.
166        let idx = self
167            .fragments
168            .push(self.fragment_capture.drain(cp_idx + 1..), tags);
169        self.fragment_capture.pop();
170
171        self.fragment_depth = self.fragment_depth.saturating_sub(1);
172
173        Some(idx)
174    }
175
176    /// Returns true if currently inside a fragment capture.
177    pub fn in_fragment_capture(&self) -> bool {
178        self.fragment_depth > 0
179    }
180
181    /// Push a tag onto the current fragment being captured.
182    pub fn push_fragment_tag(&mut self, tag: String) {
183        if let Some(pending) = self.fragment_pending_tags.last_mut() {
184            pending.push(tag);
185        }
186    }
187
188    /// Read access to a finalized fragment's tags.
189    pub fn fragment_tags(&self, idx: u32) -> Option<&[String]> {
190        self.fragments.tags(idx)
191    }
192
193    /// Read access to all finalized fragments.
194    pub fn fragments(&self) -> &Fragments {
195        &self.fragments
196    }
197
198    /// Read access to a finalized fragment's parts.
199    pub fn fragment(&self, idx: u32) -> Option<&[OutputPart]> {
200        self.fragments.parts(idx)
201    }
202
203    /// Where a fragment's text came from (#3435): the FIRST `LineRef`'s
204    /// line-table `source_location` — the same "first wins" rule a
205    /// delivered line uses in `flush_lines`, through the same scope-table
206    /// selection (`scope_table_idx`, never `line_tables` directly).
207    pub fn fragment_source(
208        &self,
209        idx: u32,
210        program: &Program,
211        line_tables: &[Vec<LineEntry>],
212    ) -> Option<brink_format::SourceLocation> {
213        self.fragment(idx)?.iter().find_map(|part| match part {
214            OutputPart::LineRef {
215                container_idx,
216                line_idx,
217                ..
218            } => {
219                let scope_idx = program.scope_table_idx(*container_idx) as usize;
220                line_tables
221                    .get(scope_idx)
222                    .and_then(|t| t.get(*line_idx as usize))
223                    .and_then(|entry| entry.source_location.clone())
224            }
225            _ => None,
226        })
227    }
228
229    /// Resolve a fragment's parts against the current line tables.
230    pub fn resolve_fragment(
231        &self,
232        idx: u32,
233        program: &Program,
234        line_tables: &[Vec<LineEntry>],
235        resolver: Option<&dyn PluralResolver>,
236    ) -> String {
237        match self.fragment(idx) {
238            Some(parts) => resolve_parts(parts, program, line_tables, resolver, &self.fragments),
239            None => String::new(),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use alloc::string::ToString;
247    use alloc::vec;
248
249    use super::*;
250
251    fn text(s: &str) -> OutputPart {
252        OutputPart::Text(s.to_string())
253    }
254
255    /// Nested captures land in one arena, each addressed by its own span:
256    /// the inner fragment finalizes first (index 0), the outer keeps
257    /// capturing and finalizes second (index 1) with only its own parts —
258    /// the Checkpoint markers never reach the store.
259    #[test]
260    fn nested_captures_share_the_arena_with_disjoint_spans() {
261        let mut buf = OutputBuffer::new();
262        buf.begin_fragment();
263        buf.push_text("outer-a");
264        buf.begin_fragment();
265        buf.push_text("inner");
266        buf.push_fragment_tag("t".to_string());
267        let inner = buf.end_fragment().expect("inner capture open");
268        buf.push_text("outer-b");
269        let outer = buf.end_fragment().expect("outer capture open");
270
271        assert_eq!((inner, outer), (0, 1));
272        assert_eq!(buf.fragments().len(), 2);
273        assert_eq!(buf.fragment(inner), Some(&[text("inner")][..]));
274        assert_eq!(buf.fragment_tags(inner), Some(&["t".to_string()][..]));
275        assert_eq!(
276            buf.fragment(outer),
277            Some(&[text("outer-a"), text("outer-b")][..])
278        );
279        assert_eq!(buf.fragment_tags(outer), Some(&[][..]));
280        assert!(buf.fragment(2).is_none());
281        assert!(!buf.in_fragment_capture());
282    }
283
284    /// The owning form round-trips through the store in both directions,
285    /// preserving order, parts and tags.
286    #[test]
287    fn owning_form_round_trips_through_the_store() {
288        let owned = vec![
289            Fragment {
290                parts: vec![text("a"), OutputPart::Newline],
291                tags: vec!["x".to_string()],
292            },
293            Fragment {
294                parts: vec![],
295                tags: vec![],
296            },
297            Fragment {
298                parts: vec![OutputPart::Spring, text("c")],
299                tags: vec!["y".to_string(), "z".to_string()],
300            },
301        ];
302        let store = Fragments::from(owned.clone());
303        assert_eq!(store.len(), 3);
304        assert_eq!(store.parts(1), Some(&[][..]));
305        assert_eq!(store.get(2).map(|f| f.tags.len()), Some(2));
306        assert_eq!(store.to_vec(), owned);
307        assert_eq!(store.iter().count(), 3);
308    }
309}