latexml_core/common/relaxng/mod.rs
1//! Native Rust port of LaTeXML's `LaTeXML::Common::Model::RelaxNG`.
2//!
3//! Walks a RelaxNG XML schema, builds an in-memory pattern AST, simplifies
4//! it (binding/grammar/include resolution, definition recording), and can
5//! emit the LaTeX manual.tex consumed by `latexmlman.sty` for schema
6//! documentation.
7//!
8//! Three sub-modules carry the implementation, mirroring the natural
9//! sections of the upstream Perl source:
10//!
11//! * [`scan`] — RNG XML → AST (port of `scanPattern` etc., L100–390).
12//! * [`simplify`] — AST normalization (port of `simplify*`, L438–525).
13//! * [`tex`] — schema-doc TeX emission (port of `documentModules`, `toTeX*`, L550–815).
14//!
15//! The shared state — definition tables, element index, "Used by" graph —
16//! lives on [`Relaxng`] and is populated by `scan` + `simplify`, then
17//! consumed by `tex`. The Perl original mutates `$$self{...}` from
18//! several methods at once; the Rust version threads `&mut self` the
19//! same way.
20
21use std::collections::BTreeSet;
22
23use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
24
25use crate::{
26 common::{model::LTX_NAMESPACE, xml::XML_NS},
27 document::Document,
28};
29
30pub mod embedded;
31pub mod scan;
32pub mod simplify;
33pub mod tex;
34
35// ----- AST ----------------------------------------------------------------
36
37/// Combiner kind on a `<define>` element.
38///
39/// Bare `<define>` is `Group`; `<define combine="choice">` is `Choice`;
40/// `<define combine="interleave">` is `Interleave`. Mirrors the suffix on
41/// upstream's `def`/`defchoice`/`definterleave` ops.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum DefCombiner {
44 Group,
45 Choice,
46 Interleave,
47}
48
49/// Combiner kind for a `<group|interleave|choice|optional|zeroOrMore|
50/// oneOrMore|list>` pattern wrapper.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CombineOp {
53 Group,
54 Interleave,
55 Choice,
56 Optional,
57 ZeroOrMore,
58 OneOrMore,
59 List,
60}
61
62/// One node in the RelaxNG AST, mirroring Perl `RelaxNG.pm`'s
63/// `[$op, $name, @forms]` arrays.
64///
65/// The names here line up 1:1 with the Perl op strings:
66///
67/// | Perl op | Rust variant |
68/// |--------------------|--------------------|
69/// | `ref` | [`Pattern::Ref`] |
70/// | `parentref` | [`Pattern::ParentRef`] |
71/// | `elementref` | [`Pattern::ElementRef`] (added during simplify) |
72/// | `def`/`defchoice`/`definterleave` | [`Pattern::Def`] (combiner discriminates) |
73/// | `element` | [`Pattern::Element`] |
74/// | `attribute` | [`Pattern::Attribute`] |
75/// | `start` | [`Pattern::Start`] |
76/// | `value` | [`Pattern::Value`] |
77/// | `data` | [`Pattern::Data`] |
78/// | `doc` | [`Pattern::Doc`] |
79/// | `combination` | [`Pattern::Combination`] |
80/// | `grammar` | [`Pattern::Grammar`] |
81/// | `module` | [`Pattern::Module`] |
82/// | `override` | [`Pattern::Override`] (consumed by simplify) |
83/// | `'#PCDATA'` (string leaf) | [`Pattern::Text`] |
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum Pattern {
86 /// Reference to a defined pattern. `qname` is the bare name during
87 /// `scan` and `binding:name` after `simplify`.
88 Ref { qname: String },
89 /// Reference to a parent grammar's defined pattern (replaced by
90 /// `Ref` during simplify).
91 ParentRef { qname: String },
92 /// Reference to an element by tag name (introduced during simplify
93 /// when a `Def` resolves to a single `Element`).
94 ElementRef { qname: String },
95 /// `<define>` (or `combine="choice"|"interleave"`).
96 Def {
97 combiner: DefCombiner,
98 name: String,
99 body: Vec<Pattern>,
100 },
101 /// `<element name="...">CONTENT</element>`.
102 Element { name: String, body: Vec<Pattern> },
103 /// `<attribute name="...">CONTENT</attribute>`.
104 Attribute { name: String, body: Vec<Pattern> },
105 /// `<start>...</start>`.
106 Start { body: Vec<Pattern> },
107 /// `<value>X</value>` — a literal value (typically for attributes).
108 Value(String),
109 /// `<data type="X"/>` — a typed datum.
110 Data(String),
111 /// `<a:documentation>X</a:documentation>` — annotation text.
112 Doc(String),
113 /// `<group|interleave|choice|optional|zeroOrMore|oneOrMore|list>...</...>`.
114 ///
115 /// `<mixed>` is normalised here into `Combination { Interleave, [Text, …] }`.
116 Combination { op: CombineOp, body: Vec<Pattern> },
117 /// `<grammar>...</grammar>` — defines a fresh symbol scope. Replaced
118 /// by its `start` pattern after simplify.
119 Grammar { name: String, body: Vec<Pattern> },
120 /// External / included module: contents from a separate schema file,
121 /// recorded in [`Relaxng::modules`] for documentation.
122 Module { name: String, body: Vec<Pattern> },
123 /// `<include>...</include>` with override rules (consumed by simplify
124 /// — patches the inner `Module` and disappears).
125 Override {
126 module: Box<Pattern>,
127 replacements: Vec<Pattern>,
128 },
129 /// `#PCDATA` — text leaf.
130 Text,
131}
132
133// ----- Schema state -------------------------------------------------------
134
135/// Internal representation of a RelaxNG schema. Built by [`scan`] and
136/// [`simplify`]; consumed by [`tex`] (and, for runtime validation,
137/// would be consumed by `Model::add_tag_content` etc.).
138///
139/// The mutable fields beyond `name` and `modules` are populated during
140/// `simplify`:
141///
142/// * [`elementdefs`](Relaxng::elementdefs) — pattern qname → element tag, when a pattern resolves
143/// to a single element.
144/// * [`element_reverse_defs`](Relaxng::element_reverse_defs) — inverse of `elementdefs`.
145/// * [`elements`](Relaxng::elements) — element tag → list of body patterns, accumulating across
146/// overrides / re-definitions.
147/// * [`defs`](Relaxng::defs) — pattern qname → its (combined) body pattern.
148/// * [`def_combiner`](Relaxng::def_combiner) — pattern qname → the combiner that won the most
149/// recent definition.
150/// * [`uses_name`](Relaxng::uses_name) — pattern qname → set of containers that reference it: `pattern:QNAME`
151/// for refs at define scope, `element:TAG@pattern:HOST` for refs inside an `element TAG {…}`
152/// hosted by define HOST (bare `element:TAG` when the element sits outside any define). Drives
153/// the "Used by" lists in the schema docs; `tex::symbol_uses` reports the element or the host
154/// pattern, whichever identifies the definition uniquely.
155/// * [`internal_grammars`](Relaxng::internal_grammars) — counter for naming embedded `<grammar>`
156/// blocks (`grammar1`,
157/// `grammar2`, …).
158#[derive(Debug)]
159pub struct Relaxng {
160 /// Top-level schema name (typically the .rng filename without ext).
161 pub name: String,
162 /// Modules in document-order. Populated by [`simplify`]; each entry is
163 /// a `Pattern::Module` whose body is populated retroactively (the
164 /// Perl push-then-extend pattern).
165 pub modules: Vec<Pattern>,
166
167 pub elementdefs: HashMap<String, String>,
168 pub element_reverse_defs: HashMap<String, String>,
169 pub elements: HashMap<String, Vec<Pattern>>,
170 pub defs: HashMap<String, Pattern>,
171 pub def_combiner: HashMap<String, DefCombiner>,
172 pub uses_name: HashMap<String, HashSet<String>>,
173 pub internal_grammars: u32,
174
175 /// Document-namespace prefix → URI, populated as the scanner sees
176 /// `xmlns:` attributes on RelaxNG nodes.
177 pub document_namespaces: HashMap<String, String>,
178
179 /// URI → code prefix, seeded from the model's `code_namespace_prefixes`
180 /// before scanning (`Model::load_schema`). Lets a namespace referenced only
181 /// as a default `ns=` (no `xmlns:` prefix in the schema) resolve to the
182 /// conventional prefix the engine already registered — e.g.
183 /// `http://dlmf.nist.gov/LaTeXML` → `ltx` from `base_schema` — instead of a
184 /// synthetic `namespaceN`. Port of Perl `encodeQName` → `getNamespacePrefix`
185 /// consulting `code_namespace_prefixes` (#652).
186 pub code_namespace_prefixes: HashMap<String, String>,
187
188 /// The master grammar's `<grammar ns="…">` URI — populated by the
189 /// first call to `scan_external` (i.e. the schema entry point).
190 /// Subsequent included grammars don't overwrite it. Used by the
191 /// schema-doc emitter to auto-register the corresponding namespace
192 /// prefix for elision in display names.
193 pub primary_namespace: Option<String>,
194
195 /// Namespace prefixes whose `prefix:` part should be elided from
196 /// rendered display names in the schema docs (`clean_tex_name`),
197 /// since they're contextually obvious for the schema. Auto-populated
198 /// from `primary_namespace` when the schema-doc emission starts —
199 /// e.g. LaTeXML's `default namespace = "http://dlmf.nist.gov/LaTeXML"`
200 /// (mapped to the `ltx` prefix) becomes a strip-prefix so display
201 /// names read `para` rather than `ltx:para`.
202 pub display_strip_prefixes: Vec<String>,
203
204 /// The simplified `<start>` patterns — the grammar's document root
205 /// content. Captured by [`Self::load_schema`] so [`Self::compute_model_data`]
206 /// can distil `#Document`'s allowed children (Perl `RelaxNG.pm`'s
207 /// `extractContent('#Document', @schema)`, L71).
208 pub start: Vec<Pattern>,
209}
210
211impl Default for Relaxng {
212 fn default() -> Self {
213 Relaxng {
214 name: String::from("LaTeXML"),
215 modules: Vec::new(),
216 elementdefs: HashMap::default(),
217 element_reverse_defs: HashMap::default(),
218 elements: HashMap::default(),
219 defs: HashMap::default(),
220 def_combiner: HashMap::default(),
221 uses_name: HashMap::default(),
222 internal_grammars: 0,
223 document_namespaces: HashMap::default(),
224 code_namespace_prefixes: HashMap::default(),
225 primary_namespace: None,
226 display_strip_prefixes: Vec::new(),
227 start: Vec::new(),
228 }
229 }
230}
231
232impl Relaxng {
233 /// Construct an empty schema state. Use [`Self::load_schema`] to
234 /// populate from an RNG file.
235 pub fn new(name: impl Into<String>) -> Self {
236 Relaxng {
237 name: name.into(),
238 ..Self::default()
239 }
240 }
241
242 /// Register a `prefix → URI` binding ahead of scanning. Mirrors
243 /// `Model::register_namespace` for standalone callers (which don't
244 /// have a live `Model` to consult). Callers that already populated
245 /// the schema's `xmlns:` declarations dynamically don't need this;
246 /// it's intended for namespaces that trang flattens away — the most
247 /// common case is a `.rnc` whose `default namespace = "..."` carries
248 /// no prefix, so the URI is preserved on `<grammar ns="..."/>` but
249 /// no `xmlns:` survives. Later calls overwrite earlier ones.
250 pub fn register_namespace(&mut self, prefix: impl Into<String>, uri: impl Into<String>) {
251 self.document_namespaces.insert(prefix.into(), uri.into());
252 }
253
254 /// Register the prefixes that `Model::new_default()` ships with the
255 /// LaTeXML schema (`xml`, `ltx`, `svg`, `xlink`, `m`, `xhtml`). The
256 /// runtime `Model` resolves these via its own registry, so this is
257 /// only needed for *standalone* tooling (the `genschema_oxide`
258 /// binary, integration tests against `LaTeXML.rng`) where we don't
259 /// have a Model object to consult. Returns `&mut self` for chaining.
260 pub fn with_latexml_defaults(&mut self) -> &mut Self {
261 self.register_namespace("xml", XML_NS);
262 self.register_namespace("ltx", LTX_NAMESPACE);
263 self.register_namespace("svg", "http://www.w3.org/2000/svg");
264 self.register_namespace("xlink", "http://www.w3.org/1999/xlink");
265 self.register_namespace("m", "http://www.w3.org/1998/Math/MathML");
266 self.register_namespace("xhtml", "http://www.w3.org/1999/xhtml");
267 self
268 }
269
270 /// Register a namespace prefix to elide from rendered display names
271 /// in the schema docs. Idempotent — duplicates are dropped.
272 pub fn register_display_prefix_strip(&mut self, prefix: impl Into<String>) {
273 let prefix = prefix.into();
274 if !self.display_strip_prefixes.contains(&prefix) {
275 self.display_strip_prefixes.push(prefix);
276 }
277 }
278
279 /// Look up `primary_namespace` in `document_namespaces` and, if it
280 /// resolves to a non-empty prefix, register that prefix for elision
281 /// from rendered display names. Call after scan + simplify, before
282 /// `tex::document_modules`. No-op when there's no primary namespace
283 /// or when its prefix is the default (empty) one.
284 ///
285 /// When two prefixes map to the same URI (rare — a schema author
286 /// could declare `xmlns:foo="…"` and `xmlns:bar="…"` to the same
287 /// namespace), the lexicographically smallest prefix wins. We sort
288 /// the candidates explicitly so the chosen prefix is identical
289 /// between builds: `document_namespaces` is a `FxHashMap`, whose
290 /// iteration order isn't a stable contract.
291 pub fn auto_strip_primary_namespace(&mut self) {
292 let Some(uri) = self.primary_namespace.clone() else {
293 return;
294 };
295 let mut candidates: Vec<&String> = self
296 .document_namespaces
297 .iter()
298 .filter(|(p, u)| !p.is_empty() && u.as_str() == uri.as_str())
299 .map(|(p, _)| p)
300 .collect();
301 candidates.sort();
302 if let Some(p) = candidates.first().map(|s| (*s).clone()) {
303 self.register_display_prefix_strip(p);
304 }
305 }
306
307 /// Insert a `<?latexml RelaxNGSchema="..."?>` processing instruction
308 /// on the given document.
309 pub fn add_schema_declaration(&self, document: &mut Document) {
310 let mut attributes = HashMap::default();
311 attributes.insert(String::from("RelaxNGSchema"), self.name.clone());
312 document
313 .insert_pi("latexml", Some(attributes))
314 .expect("should never fail");
315 }
316
317 /// Load + scan + simplify the schema named in `self.name` (or
318 /// `name_override`). Searches `search_paths` for the .rng file. After
319 /// success, the AST sits in [`Self::modules`] and the lookup tables
320 /// are populated.
321 pub fn load_schema(
322 &mut self,
323 name: &str,
324 search_paths: &[&std::path::Path],
325 ) -> Result<(), scan::ScanError> {
326 let raw = scan::scan_external(self, name, None, search_paths)?;
327 self.start = simplify::simplify_top(self, raw);
328 Ok(())
329 }
330
331 /// Distil the scanned+simplified schema into the tag/attribute/namespace/class
332 /// tables the runtime `Model` consults (`canContain`/`canHaveAttribute`/
333 /// `isInSchemaClass`). A faithful port of Perl `Common/Model/RelaxNG.pm`'s
334 /// post-scan loop (L70-95) and `extractContent` (L100-128): for each element
335 /// tag, walk its body to the set of child elements and attributes it allows;
336 /// for `#Document`, the same over the grammar `<start>`; class-defining symbols
337 /// (`grammarN:NAME.class`) become schema classes. This is the step the compiled
338 /// `.model` path bakes ahead of time (`Model::load_compiled_schema_str`); a
339 /// schema loaded from raw `.rng` at runtime (`RelaxNGSchema()`, no compiled
340 /// `.model`) needs it computed here, or `tagprop` stays empty and every element
341 /// is rejected as "not allowed" (dginev/latexml-oxide#652).
342 pub fn compute_model_data(&self) -> ModelData {
343 let mut tag_contents: Vec<(String, Vec<String>)> = Vec::new();
344 let mut tag_attributes: Vec<(String, Vec<String>)> = Vec::new();
345 let mut schema_classes: Vec<(String, Vec<String>)> = Vec::new();
346
347 // `#Document`: the start's content (Perl L71). Attributes are irrelevant.
348 let (doc_children, _) = self.extract_content(&self.start);
349 tag_contents.push(("#Document".to_string(), doc_children.into_iter().collect()));
350
351 // Each element tag → (allowed children, allowed attributes). `*:*` (the
352 // any-name element) carries no internal structure (Perl L82-85).
353 let mut tags: Vec<&String> = self.elements.keys().collect();
354 tags.sort();
355 for tag in tags {
356 if tag == "*:*" {
357 tag_contents.push((tag.clone(), vec!["*:*".to_string()]));
358 continue;
359 }
360 let (children, attrs) = self.extract_content(&self.elements[tag]);
361 tag_contents.push((tag.clone(), children.into_iter().collect()));
362 tag_attributes.push((tag.clone(), attrs.into_iter().collect()));
363 }
364
365 // Schema classes: a `def` named `grammarN:NAME.class` (Perl L91-95).
366 let mut defs: Vec<&String> = self.defs.keys().collect();
367 defs.sort();
368 for sym in defs {
369 if let Some(name) = sym
370 .strip_suffix(".class")
371 .and_then(|s| s.split_once(':').map(|(_, n)| n))
372 && let Some(def) = self.defs.get(sym)
373 {
374 let (members, _) = self.extract_content(std::slice::from_ref(def));
375 schema_classes.push((name.to_string(), members.into_iter().collect()));
376 }
377 }
378
379 ModelData {
380 tag_contents,
381 tag_attributes,
382 schema_classes,
383 namespaces: self
384 .document_namespaces
385 .iter()
386 .map(|(p, u)| (p.clone(), u.clone()))
387 .collect(),
388 }
389 }
390
391 /// Walk a pattern body to the sets of (child elements, attributes) it permits,
392 /// resolving `ref`s through `elementdefs` (single-element defs → a child) and
393 /// `defs` (other defs → inline expansion). Port of Perl `extractContent`
394 /// (`RelaxNG.pm` L100-128); the `seen` guard is Rust-only defence against a
395 /// self-referential `def` (a recursive content model) looping forever.
396 fn extract_content(&self, body: &[Pattern]) -> (BTreeSet<String>, BTreeSet<String>) {
397 let mut children: BTreeSet<String> = BTreeSet::new();
398 let mut attrs: BTreeSet<String> = BTreeSet::new();
399 let mut seen: HashSet<String> = HashSet::default();
400 let mut work: Vec<Pattern> = body.to_vec();
401 while let Some(item) = work.pop() {
402 match item {
403 Pattern::Attribute { name, .. } => {
404 attrs.insert(name);
405 },
406 Pattern::Element { name, .. } | Pattern::ElementRef { qname: name } => {
407 children.insert(name);
408 },
409 Pattern::Combination { body, .. } | Pattern::Def { body, .. } | Pattern::Start { body } => {
410 work.extend(body);
411 },
412 Pattern::Grammar { body, .. } | Pattern::Module { body, .. } => {
413 work.extend(simplify::extract_start(&body));
414 },
415 Pattern::Ref { qname } | Pattern::ParentRef { qname } => {
416 if let Some(el) = self.elementdefs.get(&qname) {
417 children.insert(el.clone());
418 } else if seen.insert(qname.clone())
419 && let Some(expansion) = self.defs.get(&qname)
420 {
421 work.push(expansion.clone());
422 }
423 },
424 Pattern::Value(_) | Pattern::Data(_) | Pattern::Text => {
425 children.insert("#PCDATA".to_string());
426 },
427 Pattern::Doc(_) | Pattern::Override { .. } => {},
428 }
429 }
430 (children, attrs)
431 }
432}
433
434/// The tables [`Relaxng::compute_model_data`] distils out of a scanned schema,
435/// ready to be pushed into the runtime `Model` (`add_tag_content` /
436/// `add_tag_attribute` / `set_schema_class` / `register_document_namespace`).
437#[derive(Debug, Default)]
438pub struct ModelData {
439 pub tag_contents: Vec<(String, Vec<String>)>,
440 pub tag_attributes: Vec<(String, Vec<String>)>,
441 pub schema_classes: Vec<(String, Vec<String>)>,
442 pub namespaces: Vec<(String, String)>,
443}
444
445#[cfg(test)]
446mod distill_tests {
447 use super::*;
448 use crate::common::relaxng::scan::scan_string;
449
450 /// #652: a raw `.rng` scanned at runtime must yield the same tag→children /
451 /// tag→attributes tables the compiled `.model` bakes ahead of time. Before the
452 /// fix the runtime scan built the AST but never distilled `tagprop`, so every
453 /// element was rejected ("<ltx:document> isn't allowed in <#Document>") and the
454 /// output was empty.
455 #[test]
456 fn compute_model_data_distills_tagprop_from_scanned_schema() {
457 let xml = r#"
458 <grammar xmlns="http://relaxng.org/ns/structure/1.0">
459 <start><ref name="document"/></start>
460 <define name="document">
461 <element name="document"><zeroOrMore><ref name="para"/></zeroOrMore></element>
462 </define>
463 <define name="para">
464 <element name="para"><attribute name="class"/><text/></element>
465 </define>
466 </grammar>
467 "#;
468 let mut rng = Relaxng::default();
469 let raw = scan_string(&mut rng, xml).expect("scan");
470 rng.start = simplify::simplify_top(&mut rng, raw);
471 let data = rng.compute_model_data();
472
473 let content = |tag: &str| -> Vec<String> {
474 data
475 .tag_contents
476 .iter()
477 .find(|(t, _)| t == tag)
478 .map(|(_, c)| c.clone())
479 .unwrap_or_default()
480 };
481 let attrs = |tag: &str| -> Vec<String> {
482 data
483 .tag_attributes
484 .iter()
485 .find(|(t, _)| t == tag)
486 .map(|(_, a)| a.clone())
487 .unwrap_or_default()
488 };
489
490 assert_eq!(
491 content("#Document"),
492 vec!["document".to_string()],
493 "the grammar <start> makes `document` the only allowed document root; got {:?}",
494 data.tag_contents
495 );
496 assert_eq!(
497 content("document"),
498 vec!["para".to_string()],
499 "document's content model allows para"
500 );
501 assert_eq!(
502 content("para"),
503 vec!["#PCDATA".to_string()],
504 "para's <text/> becomes #PCDATA content"
505 );
506 assert_eq!(
507 attrs("para"),
508 vec!["class".to_string()],
509 "para allows @class"
510 );
511 }
512}