Skip to main content

latexml_core/sxml/
fragment_index.rs

1//! Document-global facts that must survive a spill.
2//!
3//! When a subtree is spilled its DOM nodes are freed, so every registry that
4//! held a handle into it (`Document::idstore`'s `Node`s, `node_boxes`' raw
5//! pointers) must be purged — the historical finalize-SIGSEGV class. What the
6//! later phases still need from spilled content is retained HERE, as plain
7//! strings:
8//!
9//! * which segment holds a spilled `xml:id` — for `id:`-scoped rewrites, math
10//!   `XMRef` existence checks, and global id-collision dedup;
11//! * `label → id` — for `label:`-scoped rewrites (built eagerly by
12//!   `Document::load_labels_for_rewrite` today);
13//! * RDFa prefix declarations seen in spilled content — `set_rdfa_prefixes`
14//!   scans the whole DOM in eager mode, so spilled fragments must contribute
15//!   at spill time.
16//!
17//! The index is append-only during pass 1 and read-only afterwards. It can be
18//! saved beside the segments for diagnostics ([`FragmentIndex::save`] /
19//! [`FragmentIndex::load`]) in a dependency-free line format.
20
21use std::{fmt::Write as _, fs, path::Path};
22
23use rustc_hash::FxHashMap;
24
25use super::SegmentId;
26use crate::common::error::{Error, ErrorCategory, ErrorTarget, Result};
27
28/// The string-only registry of what spilled content still owes the rest of the
29/// conversion.
30#[derive(Debug, Default)]
31pub struct FragmentIndex {
32  ids:           FxHashMap<String, SegmentId>,
33  labels:        FxHashMap<String, String>,
34  rdfa_prefixes: FxHashMap<String, String>,
35}
36
37impl FragmentIndex {
38  /// A spilled node's `xml:id`, and the segment now holding it.
39  pub fn record_id(&mut self, id: &str, segment: SegmentId) {
40    self.ids.insert(id.to_string(), segment);
41  }
42
43  /// A `label → xml:id` association from spilled content.
44  pub fn record_label(&mut self, label: &str, id: &str) {
45    self.labels.insert(label.to_string(), id.to_string());
46  }
47
48  /// An RDFa prefix declaration (`prefix → uri`) seen in spilled content.
49  pub fn record_rdfa_prefix(&mut self, prefix: &str, uri: &str) {
50    self
51      .rdfa_prefixes
52      .insert(prefix.to_string(), uri.to_string());
53  }
54
55  /// Which segment holds `id`, if it was spilled.
56  pub fn id_segment(&self, id: &str) -> Option<SegmentId> { self.ids.get(id).copied() }
57
58  /// Is `id` claimed by any spilled fragment? (The global half of id-collision
59  /// dedup; the live spine's half stays in `Document::idstore`.)
60  pub fn contains_id(&self, id: &str) -> bool { self.ids.contains_key(id) }
61
62  /// The `xml:id` a spilled `label` resolves to, if any.
63  pub fn label_id(&self, label: &str) -> Option<&str> { self.labels.get(label).map(String::as_str) }
64
65  /// All spilled `label → id` associations (merged into each fragment's — and
66  /// the spine's — `rewrite_labels` before rules run, so a `label:`-scoped
67  /// rule resolves no matter which fragment holds the label).
68  pub fn labels(&self) -> impl Iterator<Item = (&str, &str)> {
69    self.labels.iter().map(|(l, i)| (l.as_str(), i.as_str()))
70  }
71
72  /// Registry sizes `(ids, labels, rdfa)` — pass-1 telemetry.
73  pub fn sizes(&self) -> (usize, usize, usize) {
74    (self.ids.len(), self.labels.len(), self.rdfa_prefixes.len())
75  }
76
77  /// All RDFa prefix declarations contributed by spilled content.
78  pub fn rdfa_prefixes(&self) -> impl Iterator<Item = (&str, &str)> {
79    self
80      .rdfa_prefixes
81      .iter()
82      .map(|(p, u)| (p.as_str(), u.as_str()))
83  }
84
85  /// Number of spilled ids recorded.
86  pub fn id_count(&self) -> usize { self.ids.len() }
87
88  /// Persist beside the segments (diagnostics / crash inspection). Line
89  /// format, one record per line: `kind\tkey\tvalue`, keys percent-escaped
90  /// for the three bytes that would break the framing (`%`, tab, newline).
91  pub fn save(&self, path: &Path) -> Result<()> {
92    let mut out = String::new();
93    for (id, seg) in &self.ids {
94      let _ = writeln!(out, "id\t{}\t{}", escape(id), seg.0);
95    }
96    for (label, id) in &self.labels {
97      let _ = writeln!(out, "label\t{}\t{}", escape(label), escape(id));
98    }
99    for (prefix, uri) in &self.rdfa_prefixes {
100      let _ = writeln!(out, "rdfa\t{}\t{}", escape(prefix), escape(uri));
101    }
102    fs::write(path, out).map_err(|e| index_error(format!("write {}: {e}", path.display())))
103  }
104
105  /// Load a saved index. Unknown record kinds are an error, not a skip — a
106  /// partially understood index would silently drop facts (fail toward
107  /// flagging).
108  pub fn load(path: &Path) -> Result<Self> {
109    let text =
110      fs::read_to_string(path).map_err(|e| index_error(format!("read {}: {e}", path.display())))?;
111    let mut index = FragmentIndex::default();
112    for (n, line) in text.lines().enumerate() {
113      if line.is_empty() {
114        continue;
115      }
116      let mut parts = line.splitn(3, '\t');
117      let (kind, key, value) = match (parts.next(), parts.next(), parts.next()) {
118        (Some(k), Some(key), Some(v)) => (k, unescape(key), unescape(v)),
119        _ => {
120          return Err(index_error(format!(
121            "malformed line {} in {}",
122            n + 1,
123            path.display()
124          )));
125        },
126      };
127      match kind {
128        "id" => {
129          let seg: u32 = value
130            .parse()
131            .map_err(|_| index_error(format!("bad segment number {value:?} at line {}", n + 1)))?;
132          index.ids.insert(key, SegmentId(seg));
133        },
134        "label" => {
135          index.labels.insert(key, value);
136        },
137        "rdfa" => {
138          index.rdfa_prefixes.insert(key, value);
139        },
140        other => {
141          return Err(index_error(format!(
142            "unknown record kind {other:?} at line {} in {}",
143            n + 1,
144            path.display()
145          )));
146        },
147      }
148    }
149    Ok(index)
150  }
151}
152
153fn escape(s: &str) -> String {
154  // Only the three bytes that would break the line framing.
155  s.replace('%', "%25")
156    .replace('\t', "%09")
157    .replace('\n', "%0A")
158}
159
160fn unescape(s: &str) -> String {
161  s.replace("%0A", "\n")
162    .replace("%09", "\t")
163    .replace("%25", "%")
164}
165
166fn index_error(details: String) -> Error {
167  Error {
168    target:   ErrorTarget::Internal,
169    category: ErrorCategory::Unexpected,
170    message:  format!("fragment-index: {details}"),
171  }
172}
173
174#[cfg(test)]
175mod tests {
176  use super::*;
177
178  #[test]
179  fn records_and_queries() {
180    let mut index = FragmentIndex::default();
181    index.record_id("S1.p3", SegmentId(0));
182    index.record_id("S2.p1", SegmentId(4));
183    index.record_label("LABEL:intro", "S1.p3");
184    index.record_rdfa_prefix("dct", "http://purl.org/dc/terms/");
185
186    assert_eq!(index.id_segment("S1.p3"), Some(SegmentId(0)));
187    assert_eq!(index.id_segment("S2.p1"), Some(SegmentId(4)));
188    assert_eq!(index.id_segment("missing"), None);
189    assert!(index.contains_id("S1.p3"));
190    assert!(!index.contains_id("LABEL:intro"));
191    assert_eq!(index.label_id("LABEL:intro"), Some("S1.p3"));
192    assert_eq!(index.id_count(), 2);
193    let prefixes: Vec<_> = index.rdfa_prefixes().collect();
194    assert_eq!(prefixes, vec![("dct", "http://purl.org/dc/terms/")]);
195  }
196
197  #[test]
198  fn save_load_round_trip_with_awkward_keys() {
199    let mut index = FragmentIndex::default();
200    // Keys exercising the escaping: tab, newline, percent, non-ASCII.
201    index.record_id("id\twith\ttabs", SegmentId(1));
202    index.record_id("id\nnewline", SegmentId(2));
203    index.record_label("LABEL:100%\u{6570}", "S1.E5");
204    index.record_rdfa_prefix("foaf", "http://xmlns.com/foaf/0.1/");
205
206    let tmp = std::env::temp_dir().join(format!("lxsxml-index-{}.tsv", std::process::id()));
207    index.save(&tmp).expect("save");
208    let loaded = FragmentIndex::load(&tmp).expect("load");
209    let _ = fs::remove_file(&tmp);
210
211    assert_eq!(loaded.id_segment("id\twith\ttabs"), Some(SegmentId(1)));
212    assert_eq!(loaded.id_segment("id\nnewline"), Some(SegmentId(2)));
213    assert_eq!(loaded.label_id("LABEL:100%\u{6570}"), Some("S1.E5"));
214    assert_eq!(loaded.rdfa_prefixes().collect::<Vec<_>>(), vec![(
215      "foaf",
216      "http://xmlns.com/foaf/0.1/"
217    )]);
218  }
219
220  #[test]
221  fn load_rejects_unknown_kinds_instead_of_skipping() {
222    let tmp = std::env::temp_dir().join(format!("lxsxml-badidx-{}.tsv", std::process::id()));
223    fs::write(&tmp, "mystery\tkey\tvalue\n").unwrap();
224    let result = FragmentIndex::load(&tmp);
225    let _ = fs::remove_file(&tmp);
226    assert!(result.is_err(), "unknown record kinds must fail loudly");
227  }
228
229  /// `xml:id` must be readable at spill-indexing time however the node was
230  /// produced. MECHANISM CORRECTION (2026-08-04, probed against libxml
231  /// 0.3.21): BOTH construction paths store the attribute NAMESPACED —
232  /// `set_attribute("xml:id", …)` resolves the predefined `xml` prefix via
233  /// `xmlSetProp`, exactly like parsing does — so this test's two branches
234  /// both exercise `node_xml_id_any_form`'s `get_attribute_ns` arm, and its
235  /// bare-read fallback is defensive dead code. (The comment that previously
236  /// claimed constructed nodes carry a plain-named attribute does not
237  /// reproduce; see `node_xml_id_any_form`'s doc.)
238  #[test]
239  fn xml_id_readable_in_both_attribute_forms() {
240    use libxml::{parser::Parser, tree::Node};
241
242    // Parsed: xml:id arrives namespaced.
243    let parsed = Parser::default()
244      .parse_string(r#"<r xmlns="urn:x"><s xml:id="parsed.id"/></r>"#)
245      .expect("parse");
246    let s_node = parsed
247      .get_root_element()
248      .and_then(|r| r.get_first_element_child())
249      .expect("child");
250    assert_eq!(
251      crate::document::Document::node_xml_id_any_form(&s_node).as_deref(),
252      Some("parsed.id"),
253      "namespaced form must read"
254    );
255
256    // Constructed: set_attribute ALSO stores the namespaced form.
257    let built = Parser::default().parse_string("<r/>").expect("parse shell");
258    let mut root = built.get_root_element().expect("root");
259    let mut child = Node::new("s", None, &built).expect("node");
260    child.set_attribute("xml:id", "built.id").expect("attr");
261    root.add_child(&mut child).expect("attach");
262    assert_eq!(
263      child.get_attribute_ns("id", "http://www.w3.org/XML/1998/namespace"),
264      Some("built.id".to_string()),
265      "set_attribute must namespace xml:id (probed libxml 0.3.21 behavior)"
266    );
267    assert_eq!(
268      crate::document::Document::node_xml_id_any_form(&child).as_deref(),
269      Some("built.id"),
270      "constructed form must read through the helper too"
271    );
272  }
273}