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}