Skip to main content

latexml_core/
digested.rs

1//! Interface layer for the full range of digested objects
2use std::{borrow::Cow, cell::RefCell, fmt, rc::Rc};
3
4use libxml::tree::Node;
5
6use crate::{
7  BoxOps, NO_PROPERTIES,
8  alignment::Alignment,
9  comment::Comment,
10  common::{
11    arena::{self, SymHashMap as HashMap, SymStr},
12    dimension::Dimension,
13    error::*,
14    font::Font,
15    locator::Locator,
16    numeric_ops::NumericOps,
17    object::Object,
18    store::Stored,
19  },
20  definition::register::RegisterValue,
21  document::Document,
22  keyvals::KeyVals,
23  list::List,
24  tbox::Tbox,
25  tokens::Tokens,
26  whatsit::Whatsit,
27};
28
29/// An `Rc`-guarded abstraction for any object encountered at the "digested" phase of processing
30// Each variant is wrapped in an `Rc`, for cheap(er) cloning when passing around
31// these objects to various auxiliary state (e.g. bookkeeping current box),
32// but also for repeatedly passing them as owned into binding closures
33// while also storing them in their owner Box.
34//
35// This model is incredibly hard to achieve with lifetimes, so
36// we employ reference counting instead (close to their original Perl design).
37// A strict OO-hierarchy of object ownership (with no auxiliary state metadata)
38// would allow a Rust-like redesign. But it could be too hard to achieve in practice.
39#[derive(Clone)]
40pub struct Digested(Rc<DigestedData>);
41/// These are all kinds of data which we consider officially supported
42/// as outputs from the digestion phase of TeX, i.e. from invoking a token.
43// Every `DigestedData` is `Rc`-allocated once per box and a huge document keeps
44// millions alive at once (issue #361 measured 11.5 M live at peak), so the
45// enum's size is paid per box. The oversized payloads are contained — `List.font`
46// is `Rc`-shared (M1), `KeyVals` is boxed (M2), and `Whatsit`'s reversion-cache
47// slots are boxed (M4) — bringing the ceiling to `Whatsit` (128 B), over a
48// `TBox`/`List` floor of 104 B. The `digested_data_size_budget` test guards
49// against future re-inflation.
50pub enum DigestedData {
51  /// A TeX Box
52  TBox(RefCell<Tbox>),
53  /// A TeX Whatsit (with interior mutability, for setters invoked while stored in state)
54  Whatsit(RefCell<Whatsit>),
55  /// A TeX Alignment (with interior mutability, for setters invoked while stored in state)
56  Alignment(Box<RefCell<Alignment>>),
57  /// A list of Digested data
58  List(RefCell<List>),
59  /// Raw Tokens that were postponed to the digestion phase uninvoked/undigested
60  Postponed(Tokens),
61  /// A LaTeX-like digested key-value map.
62  ///
63  /// Boxed to keep it off the hot `DigestedData` size budget: `KeyVals` is a
64  /// heavy struct (two `String`s, three `Vec`s, two `HashMap`s ≈ 208 B) but a
65  /// **rare** variant, whereas `DigestedData` is `Rc`-allocated once per box and
66  /// every box in a document pays its size. Inlining `KeyVals` made the whole
67  /// enum 208 B; boxing it took 40 B off *every* digested box (issue #361 memory
68  /// pass, M2), and the added indirection only touches the rare KeyVals
69  /// accesses. Measured at 0.83 % of live boxes on the #361 witness — the
70  /// rarity is what makes the trade pay.
71  KeyVals(Box<KeyVals>),
72  /// A TeX-like `RegisterValue` (e.g. a Dimension or Glue)
73  RegisterValue(RegisterValue),
74  /// A TeX comment
75  Comment(Comment),
76}
77
78// Digested and DigestedData are transparent for debugging -- just show the inner data
79impl fmt::Debug for Digested {
80  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", *self.0) }
81}
82impl fmt::Debug for DigestedData {
83  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
84    use DigestedData::*;
85    match self {
86      TBox(v) => write!(f, "{v:?}"),
87      Whatsit(v) => write!(f, "{v:?}"),
88      Alignment(a) => write!(f, "{a:?}"),
89      List(v) => write!(f, "{v:?}"),
90      Postponed(v) => write!(f, "{v:?}"),
91      KeyVals(v) => write!(f, "{v:?}"),
92      RegisterValue(v) => write!(f, "{v:?}"),
93      Comment(v) => write!(f, "{v:?}"),
94    }
95  }
96}
97
98impl PartialEq for Digested {
99  fn eq(&self, other: &Digested) -> bool {
100    use DigestedData::*;
101    match *self.0 {
102      TBox(ref tb) => {
103        if let TBox(ref tb2) = *other.0 {
104          tb == tb2
105        } else {
106          false
107        }
108      },
109      Whatsit(ref tb) => {
110        if let Whatsit(ref tb2) = *other.0 {
111          *tb.borrow() == *tb2.borrow()
112        } else {
113          false
114        }
115      },
116      Alignment(ref tb) => {
117        if let Alignment(ref tb2) = *other.0 {
118          *tb.borrow() == *tb2.borrow()
119        } else {
120          false
121        }
122      },
123      List(ref tb) => {
124        if let List(ref tb2) = *other.0 {
125          tb == tb2
126        } else {
127          false
128        }
129      },
130      Postponed(ref tb) => {
131        if let Postponed(ref tb2) = *other.0 {
132          tb == tb2
133        } else {
134          false
135        }
136      },
137      KeyVals(ref tb) => {
138        if let KeyVals(ref tb2) = *other.0 {
139          tb == tb2
140        } else {
141          false
142        }
143      },
144      RegisterValue(ref tb) => {
145        if let RegisterValue(ref tb2) = *other.0 {
146          tb == tb2
147        } else {
148          false
149        }
150      },
151      Comment(ref tb) => {
152        if let Comment(ref tb2) = *other.0 {
153          tb == tb2
154        } else {
155          false
156        }
157      },
158    }
159  }
160}
161
162// Important: we need to postpone the creation of a box until a time where
163// we have the most current font information
164impl<'a> From<&'a String> for Digested {
165  fn from(value: &'a String) -> Digested {
166    Digested(Rc::new(DigestedData::Postponed(Tokens::new(ExplodeText!(
167      value
168    )))))
169  }
170}
171impl From<String> for Digested {
172  fn from(value: String) -> Digested {
173    Digested(Rc::new(DigestedData::Postponed(Tokens::new(ExplodeText!(
174      value
175    )))))
176  }
177}
178impl From<SymStr> for Digested {
179  fn from(sym: SymStr) -> Digested {
180    let tks = SymExplodeText!(sym);
181    Digested(Rc::new(DigestedData::Postponed(Tokens::new(tks))))
182  }
183}
184
185impl From<Tokens> for Digested {
186  fn from(value: Tokens) -> Digested { Digested(Rc::new(DigestedData::Postponed(value))) }
187}
188impl From<Tbox> for Digested {
189  fn from(value: Tbox) -> Digested { Digested(Rc::new(DigestedData::TBox(RefCell::new(value)))) }
190}
191impl From<List> for Digested {
192  fn from(value: List) -> Digested { Digested(Rc::new(DigestedData::List(RefCell::new(value)))) }
193}
194impl From<Whatsit> for Digested {
195  fn from(value: Whatsit) -> Digested {
196    Digested(Rc::new(DigestedData::Whatsit(RefCell::new(value))))
197  }
198}
199impl From<Alignment> for Digested {
200  fn from(value: Alignment) -> Digested {
201    Digested(Rc::new(DigestedData::Alignment(Box::new(RefCell::new(
202      value,
203    )))))
204  }
205}
206impl From<KeyVals> for Digested {
207  fn from(value: KeyVals) -> Digested { Digested(Rc::new(DigestedData::KeyVals(Box::new(value)))) }
208}
209impl From<RegisterValue> for Digested {
210  fn from(value: RegisterValue) -> Digested {
211    Digested(Rc::new(DigestedData::RegisterValue(value)))
212  }
213}
214impl From<Comment> for Digested {
215  fn from(value: Comment) -> Digested { Digested(Rc::new(DigestedData::Comment(value))) }
216}
217
218impl<'a> From<&'a Digested> for Option<Digested> {
219  fn from(value: &'a Digested) -> Option<Digested> { Some(value.clone()) }
220}
221
222// impl<'a> From<&'a Digested> for Tokens {
223//   fn from(value: &'a Digested) -> Tokens { value.revert().unwrap() }
224// }
225// impl From<Digested> for Tokens {
226//   fn from(value: Digested) -> Tokens { value.revert().unwrap() }
227// }
228impl From<Digested> for Result<Digested> {
229  fn from(value: Digested) -> Result<Digested> { Ok(value) }
230}
231impl From<Digested> for Result<Vec<Digested>> {
232  fn from(value: Digested) -> Result<Vec<Digested>> { Ok(vec![value]) }
233}
234impl From<Digested> for Result<Option<Digested>> {
235  fn from(value: Digested) -> Result<Option<Digested>> { Ok(Some(value)) }
236}
237
238impl Default for Digested {
239  fn default() -> Self { Digested(Rc::new(DigestedData::TBox(RefCell::new(Tbox::default())))) }
240}
241
242impl fmt::Display for Digested {
243  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
244    use DigestedData::*;
245    match *self.0 {
246      TBox(ref b) => write!(f, "{}", b.borrow()),
247      List(ref l) => write!(f, "{}", l.borrow()),
248      Whatsit(ref w) => write!(f, "{}", w.borrow()),
249      Alignment(ref a) => write!(f, "{}", a.borrow()),
250      Postponed(ref t) => write!(f, "{t}"),
251      KeyVals(ref kvs) => write!(f, "{kvs}"),
252      Comment(ref c) => write!(f, "{c}"),
253      RegisterValue(ref rv) => write!(f, "{rv}"),
254    }
255  }
256}
257impl Object for Digested {
258  fn stringify(&self) -> String {
259    use DigestedData::*;
260    match *self.0 {
261      TBox(ref b) => b.borrow().stringify(),
262      List(ref l) => l.borrow().stringify(),
263      Whatsit(ref w) => w.borrow().stringify(),
264      Alignment(ref w) => w.borrow().stringify(),
265      Postponed(ref t) => (*t).stringify(),
266      KeyVals(ref kvs) => kvs.stringify(),
267      Comment(ref c) => c.stringify(),
268      RegisterValue(ref rv) => (*rv).stringify(),
269    }
270  }
271  fn get_locator(&self) -> Option<Locator> {
272    use DigestedData::*;
273    match *self.0 {
274      TBox(ref b) => b.borrow().get_locator(),
275      List(ref l) => l.borrow().get_locator(),
276      Comment(ref c) => c.get_locator(),
277      Whatsit(ref w) => w.borrow().get_locator(),
278      Alignment(ref w) => w.borrow().get_locator(),
279      KeyVals(ref kvs) => kvs.get_locator(), // KeyVals locator?
280      RegisterValue(ref rv) => rv.get_locator(),
281      Postponed(ref _t) => None, // Tokens carry no locator
282    }
283  }
284  /// The source tokens this digested value came from — digestion run
285  /// backwards, delegated to whichever variant is held.
286  ///
287  /// What `\meaning`-style introspection and the `tex` attribute are built on:
288  /// a construct that has already become boxes, a whatsit or an alignment can
289  /// still show the LaTeX that produced it. A `Postponed` variant is already
290  /// tokens and reverts to itself.
291  fn revert(&self) -> Result<Tokens> {
292    use DigestedData::*;
293    match *self.0 {
294      TBox(ref b) => b.borrow().revert(),
295      List(ref l) => l.borrow().revert(),
296      Whatsit(ref w) => w.borrow().revert(),
297      // Re-entrant guard: a broken alignment (e.g. a `\matrix`/`\pmatrix` left
298      // mid-mutation by a failed mode-group close) can re-enter its own RefCell
299      // during reversion → "already mutably borrowed" panic. Perl has no
300      // borrow-checker, so its `$alignment->revert` is plain re-entrant data
301      // access. `try_borrow` + an empty reversion on the re-entrant cycle,
302      // mirroring the base_xmath fix (75c452843d) and `compute_size`/`with_properties`.
303      Alignment(ref w) => match w.try_borrow() {
304        Ok(al) => al.revert(),
305        Err(_) => {
306          Error!(
307            "unexpected",
308            "self_referential_alignment",
309            "Reverting a re-entrant alignment to empty tokens (source text is lost)"
310          );
311          Ok(Tokens::default())
312        },
313      },
314      Postponed(ref t) => Ok(t.clone()),
315      KeyVals(ref kvs) => kvs.revert(),
316      Comment(ref c) => c.revert(),
317      RegisterValue(ref rv) => rv.revert(),
318    }
319  }
320}
321
322impl BoxOps for Digested {
323  fn unlist(&self) -> Vec<Digested> {
324    use DigestedData::*;
325    match *self.0 {
326      TBox(_) | Whatsit(_) | Alignment(_) | KeyVals(_) | Comment(_) | Postponed(_)
327      | RegisterValue(_) => {
328        vec![self.clone()]
329      },
330      List(ref l) => l.borrow().unlist(),
331    }
332  }
333  fn unlist_ref(&self) -> Vec<Cow<'_, Digested>> {
334    use DigestedData::*;
335    match *self.0 {
336      TBox(_) | Whatsit(_) | Alignment(_) | KeyVals(_) | Comment(_) | Postponed(_)
337      | RegisterValue(_) => {
338        vec![Cow::Borrowed(self)]
339      },
340      List(ref l) => l.borrow().unlist().into_iter().map(Cow::Owned).collect(),
341    }
342  }
343
344  fn be_absorbed(&self, document: &mut Document) -> Result<Vec<Node>> {
345    use DigestedData::*;
346    match &*self.0 {
347      TBox(b) => b.borrow().be_absorbed(document),
348      List(l) => l.borrow().be_absorbed(document),
349      Comment(c) => c.be_absorbed(document),
350      Whatsit(w) => w.borrow().be_absorbed(document),
351      // Re-entrant guard: a broken alignment mid-`be_absorbed_mut` (which holds
352      // an exclusive borrow) can be re-absorbed by recursive document
353      // construction → "already borrowed" panic. Absorb nothing on the
354      // re-entrant cycle (the alignment is mid-mutation; producing no nodes is
355      // the safe degradation, like `Postponed`). Mirrors `with_properties` /
356      // `compute_size` / the base_xmath fix (75c452843d).
357      Alignment(w) => match w.try_borrow_mut() {
358        Ok(mut al) => al.be_absorbed_mut(document),
359        Err(_) => {
360          Error!(
361            "unexpected",
362            "self_referential_alignment",
363            "Skipping absorption of a re-entrant alignment (its cells are lost)"
364          );
365          Ok(Vec::new())
366        },
367      },
368      KeyVals(kvs) => kvs.be_absorbed(document),
369      Postponed(_) => Ok(Vec::new()), // Postponed items absorbed silently
370      RegisterValue(_rv) => Ok(Vec::new()), // Register values not absorbable
371    }
372  }
373
374  fn with_properties<R, FnR>(&self, caller: FnR) -> R
375  where FnR: FnOnce(&HashMap<Stored>) -> R {
376    use DigestedData::*;
377    // Defensive `try_borrow`: when a Digested wrapper is mid-`be_absorbed_mut`
378    // (which holds an exclusive `borrow_mut`) and document construction
379    // recursively asks the SAME node for its properties, an infallible
380    // `.borrow()` panics with "RefCell already mutably borrowed". Fall
381    // back to NO_PROPERTIES instead — property access during the
382    // mid-absorption window is read-only and a missing-properties result
383    // is benign (matches Perl: properties default to empty in this state).
384    // Witness: 1205.0376 (article + plain-TeX `\AND`/`\at` redefs +
385    // align environment) — previously FATAL_101 panic at digested.rs:329,
386    // now succeeds.
387    match &*self.0 {
388      TBox(b) => match b.try_borrow() {
389        Ok(b) => caller(b.get_properties()),
390        Err(_) => caller(&NO_PROPERTIES),
391      },
392      List(l) => match l.try_borrow() {
393        Ok(l) => caller(l.get_properties()),
394        Err(_) => caller(&NO_PROPERTIES),
395      },
396      Comment(c) => caller(c.get_properties()),
397      Whatsit(w) => match w.try_borrow() {
398        Ok(w) => caller(w.get_properties()),
399        Err(_) => caller(&NO_PROPERTIES),
400      },
401      Alignment(w) => match w.try_borrow() {
402        Ok(w) => caller(w.get_properties()),
403        Err(_) => caller(&NO_PROPERTIES),
404      },
405      KeyVals(_) | Postponed(_) | RegisterValue(_) => caller(&NO_PROPERTIES),
406    }
407  }
408  // Note: get_properties_mut is not implemented, as it would generically require a RefCell
409  // around each type of DigestedData. Currently we are trying to keep some immutability guarantees.
410  // at the Digested interface
411
412  fn set_property<T: Into<Stored>>(&mut self, key: &str, value: T) {
413    use DigestedData::*;
414    match *self.0 {
415      // TODO: This is only possible if we have interior mutability for *ALL* Digested variants
416      // i.e. Rc<RefCell<Tbox>>, Rc<RefCell<List>>, etc.
417      TBox(ref b) => b.borrow_mut().set_property(key, value),
418      List(ref l) => l.borrow_mut().set_property(key, value),
419      Whatsit(ref w) => w.borrow_mut().set_property(key, value),
420      _ => { /* no-op for Comment/Postponed/RegisterValue/KeyVals/Alignment */ },
421    }
422  }
423
424  fn get_property(&self, key: &str) -> Option<Cow<'_, Stored>> {
425    use DigestedData::*;
426    match *self.0 {
427      TBox(ref b) => b
428        .borrow()
429        .get_property(key)
430        .map(|v| Cow::Owned(v.into_owned())),
431      List(ref l) => l
432        .borrow()
433        .get_property(key)
434        .map(|v| Cow::Owned(v.into_owned())),
435      Whatsit(ref w) => w
436        .borrow()
437        .get_property(key)
438        .map(|v| Cow::Owned(v.into_owned())),
439      _ => None,
440    }
441  }
442  fn get_string(&self) -> Result<Cow<'_, str>> {
443    use DigestedData::*;
444    match *self.0 {
445      TBox(ref b) => b.borrow().get_string().map(|v| Cow::Owned(v.into_owned())),
446      List(ref l) => l.borrow().get_string().map(|v| Cow::Owned(v.into_owned())),
447      Whatsit(ref w) => w.borrow().get_string().map(|v| Cow::Owned(v.into_owned())),
448      _ => Ok(Cow::Borrowed("")),
449    }
450  }
451  fn has_property(&self, key: &str) -> bool {
452    use DigestedData::*;
453    match *self.0 {
454      TBox(ref b) => b.borrow().has_property(key),
455      List(ref l) => l.borrow().has_property(key),
456      Whatsit(ref w) => w.borrow().has_property(key),
457      _ => false,
458    }
459  }
460  fn get_body(&self) -> Result<Option<Digested>> {
461    use DigestedData::*;
462    match *self.0 {
463      // Perl: Box::getBody returns $self; List::getBody returns $self
464      TBox(_) | List(_) => Ok(Some(self.clone())),
465      Whatsit(ref w) => w.borrow().get_body(),
466      _ => Ok(None),
467    }
468  }
469  fn get_property_bool(&self, key: &str) -> bool {
470    use DigestedData::*;
471    match *self.0 {
472      TBox(ref b) => b.borrow().get_property_bool(key),
473      List(ref l) => l.borrow().get_property_bool(key),
474      Whatsit(ref w) => w.borrow().get_property_bool(key),
475      Alignment(_) | KeyVals(_) | Comment(_) | Postponed(_) | RegisterValue(_) => false,
476    }
477  }
478  /// The box's font as a SHARED handle.
479  ///
480  /// This used to return `Cow<'_, Font>`, which was a false promise on exactly
481  /// the path that matters: the payloads live behind a `RefCell`, so a
482  /// `Cow::Borrowed` cannot outlive the borrow guard and every arm was forced
483  /// through `Cow::Owned(v.into_owned())` — a deep `Font` clone on every call,
484  /// even though `Tbox`/`List`/`Whatsit` all already hold an `Rc<Font>`.
485  ///
486  /// That clone was the single largest allocation in a conversion. Measured
487  /// 2026-07-29 with `--features dhat-heap` on 100k words of plain prose:
488  /// `Rc<Font>::new` accounted for **392 MB of the 840 MB peak (47 %)** across
489  /// 1,196,000 blocks of 344 B — one per box absorbed — reached via
490  /// `List::new` ← `append_node_box` ← `open_text` ← `absorb`. The digested
491  /// boxes themselves came to 164 MB, so the *font attached to* each box cost
492  /// 2.4x the box. No caller ever used `Cow`'s one advantage (`to_mut`).
493  fn get_font(&self) -> Result<Option<Rc<Font>>> {
494    use DigestedData::*;
495    match *self.0 {
496      // `try_borrow` rather than `borrow`: a re-entrant traversal (the same
497      // guard the other `Digested` walks use) degrades to "no font" instead of
498      // panicking mid-conversion.
499      TBox(ref b) => Ok(b.try_borrow().ok().map(|b| Rc::clone(&b.font))),
500      List(ref l) => Ok(l.try_borrow().ok().and_then(|l| l.font.clone())),
501      Whatsit(ref w) => match w.try_borrow() {
502        Ok(w) => w.get_font(),
503        Err(_) => Ok(None),
504      },
505      Postponed(ref _tks) => Ok(None),
506      _ => Ok(None),
507    }
508  }
509
510  /// Note the difference between calling `compute_size` on a Digested object, and calling it on a
511  /// concrete box type. When called on `Digested` it will opt for caching the computed sizes,
512  /// but when called on the concrete types it will always compute sizes fresh.
513  fn compute_size(&self, options: HashMap<Stored>) -> Result<(Dimension, Dimension, Dimension)> {
514    use DigestedData::*;
515    // A self-referential box (its size computation recurses into the SAME
516    // RefCell — e.g. a box that transitively contains itself) would re-enter
517    // `borrow_mut` and panic ("already borrowed"). The other traversals on
518    // `Digested` (fingerprint, serialization) already guard with `try_borrow`;
519    // do the same here and treat a re-entrant cycle as zero-size to break it.
520    // Witness: astro-ph0310145 (panicked at digested.rs Alignment arm).
521    let zero = (Dimension::new(0), Dimension::new(0), Dimension::new(0));
522    match *self.0 {
523      TBox(ref b) => match b.try_borrow_mut() {
524        Ok(mut x) => x.compute_size_and_cache(options),
525        Err(_) => Ok(zero),
526      },
527      List(ref l) => match l.try_borrow_mut() {
528        Ok(mut x) => x.compute_size_and_cache(options),
529        Err(_) => Ok(zero),
530      },
531      KeyVals(ref kvs) => kvs.compute_size(options),
532      Whatsit(ref w) => match w.try_borrow_mut() {
533        Ok(mut x) => x.compute_size_and_cache(options),
534        Err(_) => Ok(zero),
535      },
536      Alignment(ref w) => match w.try_borrow_mut() {
537        Ok(mut x) => x.compute_size_and_cache(options),
538        Err(_) => Ok(zero),
539      },
540      Postponed(_) | RegisterValue(_) | Comment(_) => Ok(zero),
541    }
542  }
543}
544
545/// Hard cap on the number of sub-boxes visited by [`Digested::cycle_fingerprint`].
546/// Bounds the fingerprint cost to O(1) per box while still sampling enough
547/// content (depth-first) to tell apart same-shaped boxes.
548const FP_BUDGET: u32 = 48;
549
550/// Per-box traversal cap for [`Digested::estimate_bytes`]. Larger than
551/// `FP_BUDGET` (the estimate is computed only on a small *sample* of the box
552/// list, so a deeper walk is affordable and improves accuracy for nested
553/// boxes).
554pub(crate) const EB_BUDGET: u32 = 256;
555
556impl Digested {
557  /// immutably borrow the inner Digested data
558  pub fn data(&self) -> &DigestedData { &self.0 }
559
560  /// A content-aware but COST-BOUNDED fingerprint for the stomach cycle guard
561  /// ([`crate::cycle_guard`]).
562  ///
563  /// Design tension: it must (a) distinguish boxes by *content* so two
564  /// different boxes that merely share a shape (e.g. two `List`s of equal
565  /// length but different children) don't collide into a false cycle, yet
566  /// (b) be cheap on the digestion path. We reconcile both with a hard
567  /// **node budget**: at most `FP_BUDGET` sub-boxes are ever visited (a
568  /// depth-first sample of the content), so cost is O(1) per box regardless
569  /// of how large or deeply nested the structure is, while the sample is rich
570  /// enough that real content differences change the hash. (It is also only
571  /// ever invoked once `box_list` has already blown past the stomach guard's
572  /// activation size, so ordinary conversions never pay for it at all.)
573  /// NOT a stable cross-process hash — for in-run loop detection only.
574  pub fn cycle_fingerprint(&self) -> u64 {
575    use std::hash::Hasher;
576    let mut h = rustc_hash::FxHasher::default();
577    let mut budget: u32 = FP_BUDGET;
578    self.fingerprint_into(&mut h, &mut budget);
579    h.finish()
580  }
581
582  // NOTE: `fingerprint_into` and `estimate_bytes_into` are PAIRED budgeted
583  // traversals over `DigestedData` (one hashes, one sizes — different
584  // per-variant work, same walk shape). Both matches are deliberately
585  // EXHAUSTIVE (no `_` catch-all): adding a `DigestedData` variant breaks
586  // both at compile time, so the two cannot silently drift on coverage —
587  // only keep that property when editing either (PR #249 review P3-11).
588  fn fingerprint_into<H: std::hash::Hasher>(&self, h: &mut H, budget: &mut u32) {
589    use std::hash::Hash;
590    if *budget == 0 {
591      return;
592    }
593    *budget -= 1;
594    match self.data() {
595      DigestedData::TBox(b) => {
596        0u8.hash(h);
597        if let Ok(tb) = b.try_borrow() {
598          tb.text.hash(h);
599        }
600      },
601      DigestedData::Whatsit(w) => {
602        1u8.hash(h);
603        if let Ok(wb) = w.try_borrow() {
604          // The creating definition's identity distinguishes whatsits of
605          // different kinds (Rc data-pointer, stable within a run); the args
606          // distinguish their content.
607          (Rc::as_ptr(&wb.definition) as *const () as usize).hash(h);
608          wb.args.len().hash(h);
609          for arg in &wb.args {
610            if *budget == 0 {
611              break;
612            }
613            match arg {
614              Some(d) => d.fingerprint_into(h, budget),
615              None => {
616                *budget -= 1;
617                0xFEu8.hash(h);
618              },
619            }
620          }
621        }
622      },
623      DigestedData::Alignment(_) => 2u8.hash(h),
624      DigestedData::List(l) => {
625        3u8.hash(h);
626        if let Ok(lb) = l.try_borrow() {
627          lb.boxes.len().hash(h);
628          for child in &lb.boxes {
629            if *budget == 0 {
630              break;
631            }
632            child.fingerprint_into(h, budget);
633          }
634        }
635      },
636      DigestedData::Postponed(t) => {
637        4u8.hash(h);
638        t.len().hash(h);
639      },
640      DigestedData::KeyVals(_) => 5u8.hash(h),
641      DigestedData::RegisterValue(r) => {
642        6u8.hash(h);
643        std::mem::discriminant(r).hash(h);
644      },
645      DigestedData::Comment(c) => {
646        7u8.hash(h);
647        c.0.hash(h);
648      },
649    }
650  }
651  /// A COST-BOUNDED estimate of the heap bytes this digested box (and its
652  /// nested content) occupies. Used by the stomach's portable
653  /// memory-budget guard ([`crate::stomach`]) to detect a runaway *by the
654  /// resource that actually matters — bytes — rather than box COUNT*, since
655  /// per-box weight varies several-fold (a bare text box vs a deeply nested
656  /// `\hbox{\raise…\hbox{…}}`). Traversal is capped at `EB_BUDGET` sub-boxes
657  /// (depth-first) so the estimate stays O(1) per box; deeply nested boxes
658  /// beyond the budget are under-counted, which is safe (the guard only needs
659  /// a monotone lower bound to catch unbounded growth).
660  pub fn estimate_bytes(&self) -> usize {
661    let mut budget: u32 = EB_BUDGET;
662    self.estimate_bytes_into(&mut budget)
663  }
664
665  // Paired with `fingerprint_into` above — keep both matches exhaustive
666  // (see the note there).
667  fn estimate_bytes_into(&self, budget: &mut u32) -> usize {
668    if *budget == 0 {
669      return 0;
670    }
671    *budget -= 1;
672    // Per-node fixed overhead: the `Rc<DigestedData>` control block + the
673    // enum discriminant + the inner `RefCell`/`Box`. Deliberately coarse.
674    const NODE: usize = 64;
675    // Note: text and the `Rc<Font>` are shared (interned / ref-counted), so they
676    // add no marginal per-box bytes. What DOES accumulate per box and dominates
677    // RSS is each box's OWNED data: the `properties` HashMap, the `tokens`
678    // source-TeX vector (`Tbox`), the args/children vectors, and — crucially —
679    // the nested children themselves.
680    //
681    // `map_bytes`: a `SymHashMap` (hashbrown) allocates a control-byte table +
682    // key/value slots at ~7/8 load; ~96 B per live entry plus the base table
683    // covers control bytes, the `Stored` value enum, and growth slack.
684    fn map_bytes(n: usize) -> usize { if n == 0 { 0 } else { 64 + n * 96 } }
685    match self.data() {
686      DigestedData::TBox(b) => {
687        let mut bytes = NODE + 48;
688        if let Ok(tb) = b.try_borrow() {
689          bytes += map_bytes(tb.properties.len());
690          bytes += tb.tokens.len() * 16; // owned source-TeX tokens
691        }
692        bytes
693      },
694      DigestedData::Whatsit(w) => {
695        let mut bytes = NODE + 64;
696        if let Ok(wb) = w.try_borrow() {
697          bytes += map_bytes(wb.properties.len());
698          bytes += wb.args.len() * 16;
699          for arg in &wb.args {
700            if *budget == 0 {
701              break;
702            }
703            if let Some(d) = arg {
704              bytes += d.estimate_bytes_into(budget);
705            }
706          }
707        }
708        bytes
709      },
710      DigestedData::List(l) => {
711        let mut bytes = NODE + 48;
712        if let Ok(lb) = l.try_borrow() {
713          bytes += map_bytes(lb.properties.len());
714          bytes += lb.boxes.len() * 8;
715          for child in &lb.boxes {
716            if *budget == 0 {
717              break;
718            }
719            bytes += child.estimate_bytes_into(budget);
720          }
721        }
722        bytes
723      },
724      DigestedData::Postponed(_) => NODE + 32,
725      DigestedData::Comment(_) => NODE + 16,
726      DigestedData::Alignment(_) | DigestedData::KeyVals(_) | DigestedData::RegisterValue(_) => {
727        NODE
728      },
729    }
730  }
731
732  // convenience subset of NumericOps, added here for now as an experiment:
733  /// Obtain the i64 value of the digested object, iff it wraps a `RegisterValue`
734  pub fn value_of(&self) -> i64 {
735    match &*self.0 {
736      DigestedData::RegisterValue(rv) => rv.clone().value_of(),
737      _ => 0,
738    }
739  }
740  /// Obtain a Dimension from the digested object, iff it wraps a `RegisterValue`
741  pub fn get_dimension(&self) -> Option<Dimension> {
742    match &*self.0 {
743      DigestedData::RegisterValue(rv) => Some(Dimension::from(rv)),
744      _ => None,
745    }
746  }
747  /// Obtain the f64 value of the digested object, iff it wraps a `RegisterValue`
748  pub fn pt_value(&self, prec: Option<u8>) -> f64 {
749    match &*self.0 {
750      DigestedData::RegisterValue(rv) => rv.clone().pt_value(prec),
751      _ => 0.0,
752    }
753  }
754  /// Predicate check - true if `any` element of the current object passes the check
755  pub fn any<F>(&self, mut check: F) -> bool
756  where F: FnMut(&Self) -> bool {
757    use DigestedData::*;
758    match &*self.0 {
759      TBox(_) | Whatsit(_) | Alignment(_) | Postponed(_) | KeyVals(_) | RegisterValue(_) => {
760        check(self)
761      },
762      Comment(_) => true,
763      List(l) => l.borrow().boxes.iter().any(check),
764    }
765  }
766
767  /// Predicate check - true if `all` elements of the current object passes the check
768  pub fn all<F>(&self, mut check: F) -> bool
769  where F: FnMut(&Self) -> bool {
770    use DigestedData::*;
771    match &*self.0 {
772      TBox(_) | Whatsit(_) | Alignment(_) | Postponed(_) | KeyVals(_) | RegisterValue(_) => {
773        check(self)
774      },
775      Comment(_) => true,
776      List(l) => l.borrow().boxes.iter().all(check),
777    }
778  }
779
780  /// Predicate check - delegates to `.is_empty()` of the underlying data
781  pub fn is_empty(&self) -> Result<bool> {
782    use DigestedData::*;
783    Ok(match *self.0 {
784      TBox(ref b) => b.borrow().is_empty(),
785      List(ref l) => l.borrow().is_empty(),
786      Whatsit(ref w) => w.borrow().is_empty()?,
787      Postponed(ref tks) => tks.is_empty(),
788      _ => false, // Comments, RegisterValues, Alignments, KeyVals are non-empty
789    })
790  }
791
792  /// Check if all items are "empty" or only spaces or otherwise skippable in a table cell.
793  /// Perl: isSkippable (Alignment.pm L484-508)
794  pub fn is_skippable(&self) -> bool {
795    use DigestedData::*;
796    match *self.0 {
797      Comment(_) => true,
798      TBox(ref b) => {
799        let b = b.borrow();
800        if b.get_property_bool("alignmentPreserve") {
801          // Perl PR #2767: explicitly preserved content is never skippable
802          false
803        } else if b.get_property_bool("isEmpty")
804          || b.get_property_bool("isSpace")
805          || b.get_property_bool("alignmentSkippable")
806        {
807          true
808        } else {
809          // Perl: getString, check if only whitespace
810          b.get_string()
811            .ok()
812            .map(|s| s.trim().is_empty())
813            .unwrap_or(false)
814        }
815      },
816      List(ref l) => {
817        let l = l.borrow();
818        // Perl PR #2767: explicitly preserved content is never skippable
819        !l.get_property_bool("alignmentPreserve") && l.boxes.iter().all(|d| d.is_skippable())
820      },
821      Whatsit(ref w) => {
822        let w = w.borrow();
823        if w.get_property_bool("alignmentPreserve") {
824          // Perl PR #2767: explicitly preserved content is never skippable
825          false
826        } else if w.get_property_bool("isEmpty")
827          || w.get_property_bool("isSpace")
828          || w.get_property_bool("alignmentSkippable")
829        {
830          true
831        } else {
832          match w.get_body() {
833            Ok(Some(body)) => body.is_skippable(),
834            _ => {
835              match w.get_property("content_box") {
836                Some(ref prop) => {
837                  // Perl: $thing->getProperty('content_box') — for \hbox etc.
838                  match &**prop {
839                    Stored::Digested(cb) => cb.is_skippable(),
840                    _ => false,
841                  }
842                },
843                _ => false,
844              }
845            },
846          }
847        }
848      },
849      Postponed(ref tks) => {
850        // Perl checks token catcodes: letters, others, active, CS are NOT skippable
851        tks.unlist_ref().iter().all(|t| {
852          let cc = t.get_catcode();
853          !matches!(
854            cc,
855            crate::token::Catcode::LETTER
856              | crate::token::Catcode::OTHER
857              | crate::token::Catcode::ACTIVE
858              | crate::token::Catcode::CS
859          )
860        })
861      },
862      _ => false,
863    }
864  }
865
866  /// Provide a way of emulating an `Undigested` argument, by requesting
867  /// raw tokens, only when they are preserved -- empty otherwise.
868  pub fn raw_tokens(&self) -> Option<&Tokens> {
869    match *self.0 {
870      DigestedData::Postponed(ref tks) => Some(tks),
871      _ => None,
872    }
873  }
874
875  /// builds an attribute-friendly String form of the digested object, suitable for XML attributes
876  pub fn to_attribute(&self) -> String {
877    match *self.0 {
878      DigestedData::RegisterValue(ref v) => v.to_attribute(),
879      _ => self.to_string(),
880    }
881  }
882
883  /// Reverts a digested object to `Tokens` and extracts a TeX-near string representation of its
884  /// content
885  pub fn untex(&self) -> Result<String> { Ok(self.revert()?.untex()) }
886
887  pub fn alignment_cell(&self) -> Option<&RefCell<Alignment>> {
888    if let DigestedData::Alignment(ref alignment) = *self.0 {
889      Some(alignment)
890    } else {
891      None
892    }
893  }
894}
895
896#[cfg(test)]
897mod tests {
898  use super::*;
899
900  /// `DigestedData` is `Rc`-allocated once per digested box, and a very large
901  /// document holds millions of them alive at once (issue #361 measured 11.5 M
902  /// live at peak), so every byte in this enum is paid per box. This budget
903  /// guards against a fat variant silently re-inflating it.
904  ///
905  /// The ceiling is currently `Whatsit` (`RefCell<Whatsit>` = 120 B) +
906  /// discriminant = 128 B, with the historically oversized payloads contained:
907  /// `List.font` is `Rc`-shared (M1), `KeyVals` is boxed (M2), and `Whatsit`'s
908  /// two reversion-cache slots are boxed (M4). Below this sit `TBox`/`List` at
909  /// 96 B, so 104 B is the floor for any further variant boxing.
910  ///
911  /// Raise this only with a deliberate justification, never to paper over an
912  /// accidental blow-up — box the offending payload instead (see the `KeyVals`
913  /// doc for when that pays: the payload must be *rare*, or the extra
914  /// allocation costs more than the per-box byte it saves).
915  #[test]
916  fn digested_data_size_budget() {
917    let size = size_of::<DigestedData>();
918    assert!(
919      size <= 128,
920      "DigestedData grew to {size} B (budget 128). A large payload re-inflated \
921       the per-box footprint — box it (cf. KeyVals, issue #361) rather than \
922       raising this budget."
923    );
924  }
925
926  #[test]
927  fn digested_from_tokens_roundtrip() {
928    let ts = Tokens::new(vec![]);
929    let d: Digested = ts.into();
930    // Default variant is Postponed for raw Tokens.
931    match &*d.0 {
932      DigestedData::Postponed(_) => {},
933      other => panic!("expected Postponed, got {other:?}"),
934    }
935  }
936
937  #[test]
938  fn digested_from_string_is_postponed_tokens() {
939    let d: Digested = "abc".to_string().into();
940    match &*d.0 {
941      DigestedData::Postponed(_) => {},
942      other => panic!("expected Postponed, got {other:?}"),
943    }
944  }
945
946  #[test]
947  fn digested_from_tbox_is_tbox_variant() {
948    let tb = Tbox::default();
949    let d: Digested = tb.into();
950    match &*d.0 {
951      DigestedData::TBox(_) => {},
952      other => panic!("expected TBox, got {other:?}"),
953    }
954  }
955
956  #[test]
957  fn digested_from_list_is_list_variant() {
958    let l = List::default();
959    let d: Digested = l.into();
960    match &*d.0 {
961      DigestedData::List(_) => {},
962      other => panic!("expected List, got {other:?}"),
963    }
964  }
965
966  #[test]
967  fn digested_from_whatsit_is_whatsit_variant() {
968    let w = Whatsit::default();
969    let d: Digested = w.into();
970    match &*d.0 {
971      DigestedData::Whatsit(_) => {},
972      other => panic!("expected Whatsit, got {other:?}"),
973    }
974  }
975
976  #[test]
977  fn digested_from_keyvals_is_keyvals_variant() {
978    let kv = KeyVals::default();
979    let d: Digested = kv.into();
980    match &*d.0 {
981      DigestedData::KeyVals(_) => {},
982      other => panic!("expected KeyVals, got {other:?}"),
983    }
984  }
985
986  #[test]
987  fn digested_clone_shares_rc() {
988    // Digested is Rc-wrapped; clone should share the same underlying
989    // RefCell, not a deep copy.
990    let tb = Tbox::default();
991    let a: Digested = tb.into();
992    let b = a.clone();
993    // Rc strong count is at least 2 now.
994    assert!(Rc::strong_count(&a.0) >= 2);
995    assert!(Rc::strong_count(&b.0) >= 2);
996  }
997
998  #[test]
999  fn digested_ref_to_option_some() {
1000    let d: Digested = Tbox::default().into();
1001    let o: Option<Digested> = (&d).into();
1002    assert!(o.is_some());
1003  }
1004
1005  fn tbox_with(text: &str) -> Digested {
1006    Tbox {
1007      text: arena::pin(text),
1008      ..Default::default()
1009    }
1010    .into()
1011  }
1012  fn list_of(items: Vec<Digested>) -> Digested {
1013    List {
1014      boxes: items,
1015      ..Default::default()
1016    }
1017    .into()
1018  }
1019
1020  /// Iteratively dismantle a deeply-nested `Digested` so its `Drop` does NOT
1021  /// recurse once per nesting level. A deep singly-owned `Rc<DigestedData>`
1022  /// chain otherwise overflows the small per-test-thread stack under CI's
1023  /// `--test-threads=2` (a SIGABRT that passes locally on the larger default
1024  /// stack). Descends into the FIRST child of each `List`, dropping the
1025  /// shallow remainder (leaf boxes + the now-childless outer) as it goes, so
1026  /// teardown is O(1) in stack depth. (The analogous PRODUCTION concern — a
1027  /// boxing-depth-cap Fatal unwinding a deep structure — is tracked
1028  /// separately; the real box-list runaway is a WIDE `Vec`, already
1029  /// iteratively dropped.)
1030  fn drain_nest(mut cur: Digested) {
1031    loop {
1032      let child = if let DigestedData::List(l) = &*cur.0 {
1033        let mut boxes = std::mem::take(&mut l.borrow_mut().boxes);
1034        (!boxes.is_empty()).then(|| boxes.swap_remove(0))
1035      } else {
1036        None
1037      };
1038      match child {
1039        Some(c) => cur = c,
1040        None => break,
1041      }
1042    }
1043  }
1044
1045  #[test]
1046  fn cycle_fingerprint_is_content_aware_for_lists() {
1047    // The whole point of recursing into a List (rather than hashing its length
1048    // alone): two lists of the SAME length but DIFFERENT content must not
1049    // collide, or the stomach cycle guard would false-positive.
1050    let ab = list_of(vec![tbox_with("a"), tbox_with("b")]);
1051    let ac = list_of(vec![tbox_with("a"), tbox_with("c")]);
1052    assert_ne!(
1053      ab.cycle_fingerprint(),
1054      ac.cycle_fingerprint(),
1055      "same-length lists with different content must NOT share a fingerprint"
1056    );
1057    // ...while structurally identical lists DO (so real cycles are still caught).
1058    let ab2 = list_of(vec![tbox_with("a"), tbox_with("b")]);
1059    assert_eq!(ab.cycle_fingerprint(), ab2.cycle_fingerprint());
1060  }
1061
1062  #[test]
1063  fn cycle_fingerprint_distinguishes_text_and_is_bounded() {
1064    assert_ne!(
1065      tbox_with("a").cycle_fingerprint(),
1066      tbox_with("b").cycle_fingerprint()
1067    );
1068    // A pathologically deep/wide nest must still return (budget-bounded) — and
1069    // remain distinguishable from a shallow one.
1070    let mut deep = tbox_with("z");
1071    for _ in 0..10_000 {
1072      deep = list_of(vec![deep, tbox_with("z")]);
1073    }
1074    let _ = deep.cycle_fingerprint(); // must not hang / overflow
1075    assert_ne!(deep.cycle_fingerprint(), tbox_with("z").cycle_fingerprint());
1076    drain_nest(deep); // O(1)-stack teardown — see `drain_nest`.
1077  }
1078
1079  #[test]
1080  fn estimate_bytes_is_positive_and_nesting_increases_it() {
1081    // Every box has some positive footprint.
1082    assert!(tbox_with("a").estimate_bytes() > 0);
1083    // A list of N boxes weighs more than a single box (the children count).
1084    let one = list_of(vec![tbox_with("a")]);
1085    let many = list_of(vec![
1086      tbox_with("a"),
1087      tbox_with("b"),
1088      tbox_with("c"),
1089      tbox_with("d"),
1090    ]);
1091    assert!(
1092      many.estimate_bytes() > one.estimate_bytes(),
1093      "a wider list must estimate heavier than a narrow one"
1094    );
1095  }
1096
1097  #[test]
1098  fn estimate_bytes_is_cost_bounded_for_deep_nests() {
1099    // A pathologically deep nest must terminate (EB_BUDGET) without hanging /
1100    // overflowing, and still return a finite positive estimate.
1101    let mut deep = tbox_with("z");
1102    for _ in 0..100_000 {
1103      deep = list_of(vec![deep]);
1104    }
1105    let est = deep.estimate_bytes();
1106    assert!(est > 0);
1107    // Budget-bounded: the walk visits at most EB_BUDGET nodes, so a 100k-deep
1108    // nest cannot estimate more than roughly EB_BUDGET node-overheads.
1109    assert!(
1110      est < (EB_BUDGET as usize) * 4096,
1111      "estimate must stay bounded regardless of nest depth (got {est})"
1112    );
1113    drain_nest(deep); // O(1)-stack teardown — see `drain_nest`.
1114  }
1115}