Skip to main content

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}