Skip to main content

latexml_core/sxml/
segment_store.rs

1//! The on-disk spill area for fragmented conversion.
2//!
3//! A segment file holds RAW, splice-ready text at every lifecycle stage —
4//! never a wrapper element. An enclosing subtree that spills LATER keeps its
5//! children's `<_spilled_ ref=…/>` placeholders as LITERAL elements in its
6//! own file (inlining them once rebuilt multi-GB segments out of chapter
7//! shells); the final assembly resolves placeholders recursively
8//! (`Document::splice_segment_text`).
9//!
10//! 1. **Spilled** ([`SegmentStore::write_segment`]) — pass 1 stores the
11//!    pre-finalize serialization of one or more sibling subtrees.
12//! 2. **Processed** ([`SegmentStore::finalize_segment`]) — pass 2 replaces
13//!    the file with the fragment's FINAL output text (post-rewrite,
14//!    post-finalize, correctly indented).
15//!
16//! Parsing a segment (pass 2) goes through [`SegmentStore::wrapped_segment`],
17//! which wraps the raw text in a `<_lxfragment>` root carrying the recorded
18//! namespace declarations — in memory, never on disk.
19//!
20//! The directory lives beside the destination file — same volume, so
21//! [`crate::watchdog::available_disk_bytes`] measures the filesystem the spill
22//! actually consumes — and is removed on [`Drop`]. After a hard kill the
23//! directory survives; its name (`.latexml-spill-<pid>-<seq>`) is deliberately
24//! self-describing so a user knows what to delete.
25
26use std::{
27  fs,
28  path::{Path, PathBuf},
29};
30
31use crate::common::error::{Error, ErrorCategory, ErrorTarget, Result};
32
33/// Identifies one spilled segment within its [`SegmentStore`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct SegmentId(pub u32);
36
37impl std::fmt::Display for SegmentId {
38  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) }
39}
40
41/// Per-segment facts recorded at spill time and consumed when the segment is
42/// re-materialized in pass 2.
43#[derive(Debug, Clone, Default)]
44pub struct SegmentMeta {
45  /// Spine depth of the spilled subtree's insertion point (the depth the
46  /// serializer must resume at for indentation to match the eager output).
47  pub depth:      usize,
48  /// The spill parent's `noindent_children` — the `noindent` value the eager
49  /// serializer would have passed when recursing into these children
50  /// (schema-driven: whether the parent can contain `#PCDATA`). Pass-2
51  /// re-serialization must use the same value or indentation diverges.
52  pub noindent:   bool,
53  /// The ancestor font context at the spill point, in `Font::to_string` form —
54  /// the seed `finalize_rec` needs so per-fragment finalize resolves fonts
55  /// exactly as the whole-document walk would have.
56  pub font:       Option<String>,
57  /// Namespace declarations (`(prefix, uri)`) the wrapper must carry so the
58  /// segment parses stand-alone. In the eager DOM these live on the document
59  /// root (hoisted during build); a spilled subtree's own serialization does
60  /// not repeat them.
61  pub namespaces: Vec<(String, String)>,
62  /// The nearest ancestor SECTION's `xml:id` on the spine at the spill point.
63  /// Scope-gated processing (`\lxDeclare` section scoping) resolves a token's
64  /// section by walking ancestors — which a spilled-from-inside-a-section
65  /// fragment no longer has.
66  pub section_id: Option<String>,
67  /// The qname of the spilled run's PARENT element (the spine node the
68  /// segment splices back under). Pass-2 finalize consults the parent for
69  /// schema decisions — e.g. collapsing an attribute-less `ltx:text` font
70  /// wrapper requires `can_contain(parent, grandchild)` — and the parse
71  /// wrapper (`ltx:_lxfragment`) is not in the model, so the recorded real
72  /// parent substitutes for it (witness: tests/digestion/dollar.tex kept an
73  /// empty `<text>` around an inline-block that eager collapsed).
74  pub parent:     Option<String>,
75  /// Every `xml:id` on the spilled run's ANCESTOR chain at the spill point.
76  /// A `label:`/`id:`-scoped rewrite whose scope node is one of these
77  /// ancestors covers the WHOLE fragment (the fragment sits inside the
78  /// scope subtree), but the node itself lives in another fragment — the
79  /// selection would come up empty (witness: tests/math/simplemath.tex,
80  /// where `label:sec:restricted` stamps `role=FUNCTION` inside a section
81  /// whose shell spills separately from its paragraphs).
82  pub ancestors:  Vec<String>,
83}
84
85/// The element wrapping a spilled segment's sibling subtrees. Matches the
86/// existing fragment-parsing convention (`common/xml.rs::FRAGMENT_WRAPPER`):
87/// never part of a document, only a parse vehicle.
88const SEGMENT_WRAPPER: &str = "_lxfragment";
89
90/// The on-disk spill area: numbered segment files plus their in-RAM metadata.
91#[derive(Debug)]
92pub struct SegmentStore {
93  dir:     PathBuf,
94  metas:   Vec<SegmentMeta>,
95  /// Historical: segments inlined into an enclosing one. Nested spills now
96  /// stay nested (literal placeholders + recursive assembly splice), so
97  /// nothing retires in the current pipeline; the mechanism remains for the
98  /// store's API stability.
99  retired: Vec<bool>,
100}
101
102impl SegmentStore {
103  /// Create the spill directory beside `dest` (the conversion's output file or
104  /// directory), so disk-headroom checks and the spill share a volume.
105  pub fn create(dest: &Path) -> Result<Self> {
106    let parent = if dest.is_dir() {
107      dest
108    } else {
109      dest.parent().unwrap_or_else(|| Path::new("."))
110    };
111    // Pid alone is NOT unique: two concurrent streaming conversions in one
112    // process with destinations in the same parent (in-process test harnesses;
113    // any embedder running conversions on several threads) would share the
114    // directory, and the first store's Drop removes it under the other
115    // (witness: 113_streaming_core's two byte-identity tests under plain
116    // `cargo test`, which shares one process — segment-store write ENOENT).
117    // A process-wide sequence keeps the name self-describing AND unique.
118    static SPILL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
119    let seq = SPILL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
120    let dir = parent.join(format!(".latexml-spill-{}-{seq}", std::process::id()));
121    fs::create_dir_all(&dir).map_err(|e| store_error(format!("create {}: {e}", dir.display())))?;
122    Ok(SegmentStore {
123      dir,
124      metas: Vec::new(),
125      retired: Vec::new(),
126    })
127  }
128
129  /// The spill directory (for disk-headroom checks against its volume).
130  pub fn dir(&self) -> &Path { &self.dir }
131
132  /// Number of segments written so far.
133  pub fn len(&self) -> usize { self.metas.len() }
134
135  /// True when nothing has been spilled.
136  pub fn is_empty(&self) -> bool { self.metas.is_empty() }
137
138  /// The segment ids in spill (= document) order.
139  pub fn ids(&self) -> impl Iterator<Item = SegmentId> {
140    (0..self.metas.len() as u32).map(SegmentId)
141  }
142
143  /// The path of a segment's file (exists only after `write_segment`).
144  pub fn segment_path(&self, id: SegmentId) -> PathBuf {
145    self.dir.join(format!("segment-{:06}.xml", id.0))
146  }
147
148  /// Spill one or more serialized sibling subtrees as a new segment (raw,
149  /// splice-ready text — see the module doc for why no wrapper is written).
150  pub fn write_segment(&mut self, xml: &str, meta: SegmentMeta) -> Result<SegmentId> {
151    let id = SegmentId(self.metas.len() as u32);
152    let path = self.segment_path(id);
153    fs::write(&path, xml).map_err(|e| store_error(format!("write {}: {e}", path.display())))?;
154    self.metas.push(meta);
155    self.retired.push(false);
156    Ok(id)
157  }
158
159  /// The segment's content wrapped for stand-alone PARSING: a `_lxfragment`
160  /// root carrying the namespace declarations recorded at spill time. Built
161  /// in memory — the file itself stays raw and splice-ready.
162  pub fn wrapped_segment(&self, id: SegmentId) -> Result<String> {
163    let meta = self.meta(id)?;
164    let mut out = String::with_capacity(256);
165    out.push('<');
166    out.push_str(SEGMENT_WRAPPER);
167    for (prefix, uri) in &meta.namespaces {
168      if prefix.is_empty() {
169        out.push_str(&format!(" xmlns=\"{uri}\""));
170      } else {
171        out.push_str(&format!(" xmlns:{prefix}=\"{uri}\""));
172      }
173    }
174    out.push('>');
175    out.push_str(&self.read_segment(id)?);
176    out.push_str(&format!("</{SEGMENT_WRAPPER}>"));
177    Ok(out)
178  }
179
180  /// Replace a spilled segment with its processed, splice-ready output text
181  /// (raw — no wrapper; appended verbatim at the placeholder during assembly).
182  pub fn finalize_segment(&mut self, id: SegmentId, output: &str) -> Result<()> {
183    let _ = self.meta(id)?; // reject unknown ids before touching the disk
184    let path = self.segment_path(id);
185    fs::write(&path, output).map_err(|e| store_error(format!("write {}: {e}", path.display())))
186  }
187
188  /// The file's current content, whichever lifecycle stage it is in.
189  pub fn read_segment(&self, id: SegmentId) -> Result<String> {
190    let _ = self.meta(id)?;
191    let path = self.segment_path(id);
192    fs::read_to_string(&path).map_err(|e| store_error(format!("read {}: {e}", path.display())))
193  }
194
195  /// Mark a segment retired: its text was inlined into an enclosing segment,
196  /// so pass 2 skips it and assembly never asks for it. The file is truncated
197  /// (the content lives in the outer segment now).
198  pub fn retire_segment(&mut self, id: SegmentId) -> Result<()> {
199    let _ = self.meta(id)?;
200    self.retired[id.0 as usize] = true;
201    let path = self.segment_path(id);
202    fs::write(&path, "").map_err(|e| store_error(format!("truncate {}: {e}", path.display())))
203  }
204
205  /// Was this segment inlined into an enclosing one?
206  pub fn is_retired(&self, id: SegmentId) -> bool {
207    self.retired.get(id.0 as usize).copied().unwrap_or(false)
208  }
209
210  /// The metadata recorded when the segment was spilled.
211  pub fn meta(&self, id: SegmentId) -> Result<&SegmentMeta> {
212    self
213      .metas
214      .get(id.0 as usize)
215      .ok_or_else(|| store_error(format!("unknown segment {id}")))
216  }
217}
218
219impl Drop for SegmentStore {
220  fn drop(&mut self) {
221    // Best-effort: a failure to clean up must never mask the conversion's own
222    // outcome. After a hard kill the directory simply survives under its
223    // self-describing name.
224    let _ = fs::remove_dir_all(&self.dir);
225  }
226}
227
228fn store_error(details: String) -> Error {
229  Error {
230    target:   ErrorTarget::Internal,
231    category: ErrorCategory::Unexpected,
232    message:  format!("segment-store: {details}"),
233  }
234}
235
236#[cfg(test)]
237mod tests {
238  use super::*;
239
240  fn meta_with_ns() -> SegmentMeta {
241    SegmentMeta {
242      depth:      2,
243      noindent:   false,
244      section_id: None,
245      parent:     None,
246      ancestors:  vec![],
247      font:       Some(String::from("italic")),
248      namespaces: vec![(
249        String::from("ltx"),
250        String::from("http://dlmf.nist.gov/LaTeXML"),
251      )],
252    }
253  }
254
255  #[test]
256  fn segment_round_trip_and_lifecycle() {
257    let tmp = std::env::temp_dir().join(format!("lxsxml-store-{}", std::process::id()));
258    fs::create_dir_all(&tmp).unwrap();
259    let dest = tmp.join("doc.xml");
260    let mut store = SegmentStore::create(&dest).expect("create store");
261    assert!(store.is_empty());
262
263    // Two sibling subtrees, non-ASCII content, in one segment.
264    let xml = "<ltx:para xml:id=\"p1\"><ltx:p>Gr\u{00fc}\u{00df}e \u{6570}\u{5b66}</ltx:p></ltx:para><ltx:para xml:id=\"p2\"/>";
265    let id = store.write_segment(xml, meta_with_ns()).expect("write");
266    assert_eq!(store.len(), 1);
267
268    // The FILE stays raw and splice-ready; the wrapped form is in-memory.
269    let raw = store.read_segment(id).expect("read");
270    assert_eq!(raw, xml, "file content is exactly the spilled text");
271    let wrapped = store.wrapped_segment(id).expect("wrap");
272    assert!(wrapped.starts_with("<_lxfragment"), "wrapped: {wrapped}");
273    assert!(wrapped.contains("xmlns:ltx=\"http://dlmf.nist.gov/LaTeXML\""));
274    assert!(wrapped.ends_with("</_lxfragment>"));
275    assert!(wrapped.contains("Gr\u{00fc}\u{00df}e \u{6570}\u{5b66}"));
276
277    // Metadata survives.
278    let meta = store.meta(id).expect("meta");
279    assert_eq!(meta.depth, 2);
280    assert_eq!(meta.font.as_deref(), Some("italic"));
281
282    // Processed form replaces the file verbatim, no wrapper.
283    store
284      .finalize_segment(id, "  <final>out</final>\n")
285      .expect("finalize");
286    assert_eq!(store.read_segment(id).unwrap(), "  <final>out</final>\n");
287
288    // Unknown ids are refused, not silently empty (fail toward flagging).
289    assert!(store.read_segment(SegmentId(7)).is_err());
290    assert!(store.finalize_segment(SegmentId(7), "x").is_err());
291
292    // Drop removes the spill dir but never the destination's directory.
293    let spill_dir = store.segment_path(id).parent().unwrap().to_path_buf();
294    drop(store);
295    assert!(!spill_dir.exists(), "spill dir cleaned on drop");
296    assert!(tmp.exists());
297    let _ = fs::remove_dir_all(&tmp);
298  }
299
300  #[test]
301  fn segment_ids_iterate_in_document_order() {
302    let tmp = std::env::temp_dir().join(format!("lxsxml-order-{}", std::process::id()));
303    fs::create_dir_all(&tmp).unwrap();
304    let mut store = SegmentStore::create(&tmp).expect("create store");
305    for k in 0..3 {
306      store
307        .write_segment(&format!("<ltx:p>{k}</ltx:p>"), meta_with_ns())
308        .expect("write");
309    }
310    let order: Vec<u32> = store.ids().map(|s| s.0).collect();
311    assert_eq!(order, vec![0, 1, 2]);
312    drop(store);
313    let _ = fs::remove_dir_all(&tmp);
314  }
315}