Skip to main content

latexml_core/common/
model.rs

1use std::{
2  cell::RefCell,
3  fs::File,
4  io::{BufRead, BufReader},
5};
6
7use libxml::tree::Node;
8use once_cell::sync::Lazy;
9use regex::Regex;
10use rustc_hash::FxHashSet as HashSet;
11
12use super::arena::SymHashMap;
13use crate::{
14  common::{
15    arena::{self, SymStr},
16    error::*,
17    relaxng::Relaxng,
18    xml::XML_NS,
19  },
20  document::Document,
21  pin,
22  util::pathname,
23};
24
25// use common::font::*;
26
27pub const LTX_NAMESPACE: &str = "http://dlmf.nist.gov/LaTeXML";
28pub type IndirectModel = SymHashMap<SymHashMap<SymStr>>;
29
30static PREFIXED_LOCALNAME_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^:]+):(.+)$").unwrap());
31static TAG_MODEL_LINE_RE: Lazy<Regex> =
32  Lazy::new(|| Regex::new(r"^([^\{]+)\{(.*?)\}\((.*?)\)$").unwrap());
33// Mirrors Perl Model.pm L149: `m/^([^:=]+):=\(?([^)]*?)\)?$/` — the
34// `\(?…\)?` pair strips the surrounding parens from
35// `classname:=(elt1,elt2,...)` so the elements split cleanly.
36static CLASS_MODEL_LINE_RE: Lazy<Regex> =
37  Lazy::new(|| Regex::new(r"^([^:=]+):=\(?([^)]*?)\)?$").unwrap());
38static NAMESPACE_MODEL_LINE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^=]+)=(.*?)$").unwrap());
39
40#[derive(Default, Debug)]
41pub struct TagFrame {
42  model:      HashSet<SymStr>,
43  attributes: HashSet<SymStr>,
44}
45
46static DEFAULT_TAG_FRAME: Lazy<TagFrame> = Lazy::new(TagFrame::default);
47
48#[derive(Default, Debug)]
49pub struct Model {
50  pub schema:                      Option<Relaxng>,
51  pub schema_data:                 Option<Vec<SymStr>>,
52  pub schema_class:                SymHashMap<HashSet<SymStr>>,
53  pub code_namespace_prefixes:     SymHashMap<SymStr>,
54  pub code_namespaces:             SymHashMap<SymStr>,
55  pub document_namespace_prefixes: SymHashMap<SymStr>,
56  pub document_namespaces:         SymHashMap<SymStr>,
57  // doctype_namespaces: SymHashMap<SymStr>,
58  // namespace_errors: usize,
59  pub permissive:                  bool,
60  pub no_compiled:                 bool,
61  pub debug_mode:                  bool,
62  pub namespace_errors:            u8,
63  pub tagprop:                     SymHashMap<TagFrame>,
64}
65
66#[thread_local]
67pub static MODEL: Lazy<RefCell<Model>> = Lazy::new(|| RefCell::new(Model::new()));
68
69macro_rules! model {
70  () => {
71    (*MODEL).borrow()
72  };
73}
74macro_rules! model_mut {
75  () => {
76    (*MODEL).borrow_mut()
77  };
78}
79
80pub fn initialize_model() {
81  let mut global_model = MODEL.borrow_mut();
82  *global_model = Model::new();
83}
84
85/// Eagerly initialize this thread's `#[thread_local]` `MODEL` Lazy.
86///
87/// `Model::new()` interns the `xml` namespace prefix/URI via `arena::pin`,
88/// so its `Lazy` initializer reaches into the `ARENA` thread-local. Forcing
89/// it at conversion entry — *after* [`arena::force_init`](crate::common::arena::force_init)
90/// and before `STATE`/`STOMACH` are built — keeps that init from running
91/// re-entrantly from within another root's initialization, the macOS
92/// `#[thread_local]` hazard behind issue #217. No behavioral change on
93/// Linux (a later `set_model` for a schema-driven model still replaces it).
94pub(crate) fn force_init() { Lazy::force(&MODEL); }
95
96impl Model {
97  pub fn new() -> Self {
98    let mut model = Model::default();
99    // model.xpath.register_function("match-font", |x, y| {font::match_font(x,y)})
100    model.register_namespace("xml", Some(XML_NS));
101    model.register_document_namespace("xml", Some(XML_NS));
102    model
103  }
104  ///**********************************************************************
105  /// Namespaces
106  ///**********************************************************************
107  /// There are TWO namespace mappings!!!
108  /// One for coding, one for the document output.
109  ///
110  /// Coding: this namespace mapping associates prefixes to namespace URIs for
111  ///   use in the latexml code, constructors and such.
112  ///   This must be a one to one mapping and there are no default namespaces.
113  /// Document: this namespace mapping associates prefixes to namespace URIs
114  ///   as used in the generated document, and will be the
115  ///   set of prefixes used in the generated output.
116  ///   This mapping may also use a prefix of "#default" which is for
117  ///   the unprefixed form of elements (not used for attributes!)
118  pub fn register_namespace(&mut self, codeprefix: &str, namespace_opt: Option<&str>) {
119    self.register_namespace_sym(arena::pin(codeprefix), namespace_opt.map(arena::pin))
120  }
121  pub fn register_namespace_sym(&mut self, codeprefix: SymStr, namespace_opt: Option<SymStr>) {
122    // double-check empty strings are None
123    let namespace_opt_checked = namespace_opt.filter(|val| *val != pin!(""));
124    match namespace_opt_checked {
125      Some(namespace) => {
126        self
127          .code_namespace_prefixes
128          .insert_sym(namespace, codeprefix);
129        self.code_namespaces.insert_sym(codeprefix, namespace);
130      },
131      None => {
132        if let Some(prev) = self.code_namespaces.get_sym(codeprefix) {
133          self.code_namespace_prefixes.remove_sym(*prev);
134        };
135        self.code_namespaces.remove_sym(codeprefix);
136      },
137    };
138  }
139
140  /// Register a prefix in the DOCUMENT mapping — the second of the two
141  /// mappings described on [`Model::register_namespace`] above.
142  ///
143  /// An empty `docprefix` is the `#default` entry, i.e. the unprefixed form of
144  /// elements (never attributes). `None` for the namespace unbinds the prefix.
145  pub fn register_document_namespace(&mut self, docprefix: &str, namespace_opt: Option<&str>) {
146    let default_sym = pin!("#default");
147    let docprefix_sym = if docprefix.is_empty() {
148      default_sym
149    } else {
150      arena::pin(docprefix)
151    };
152
153    match namespace_opt {
154      Some(namespace) => {
155        // Since the default namespace url can still ALSO have a prefix associated,
156        // we prepend "DEFAULT#url" when using as a hash key in the prefixes table.
157        let ns_sym = arena::pin(namespace);
158        let regnamespace = if docprefix_sym == default_sym {
159          arena::pin(s!("DEFAULT#{namespace}"))
160        } else {
161          ns_sym
162        };
163        self
164          .document_namespace_prefixes
165          .insert_sym(regnamespace, docprefix_sym);
166        self.document_namespaces.insert_sym(docprefix_sym, ns_sym);
167      },
168      None => {
169        if let Some(prev) = self.document_namespaces.get_sym(docprefix_sym) {
170          self.document_namespace_prefixes.remove_sym(*prev);
171        };
172        self.document_namespaces.remove_sym(docprefix_sym);
173      },
174    };
175  }
176
177  pub fn set_relaxng_schema(&mut self, schema: &str) {
178    self.schema_data = Some(vec![pin!("RelaxNG"), arena::pin(schema)]);
179  }
180  /// TODO: This is another component that would fit perfectly as a compiler plugin.
181  /// For now, simply reimplementing the runtime loading of
182  /// LaTeXML.model as-is from Model.pm
183  pub fn load_compiled_schema(&mut self, path: &str) {
184    let compiled_fh = File::open(path).unwrap();
185    let compiled_reader = BufReader::new(&compiled_fh);
186    let content: String = compiled_reader
187      .lines()
188      .map_while(std::result::Result::ok)
189      .collect::<Vec<_>>()
190      .join("\n");
191    self.load_compiled_schema_str(&content, path);
192  }
193
194  /// Same as [`Self::load_compiled_schema`] but consumes an already-
195  /// loaded `.model` body. `source` is used purely for diagnostic
196  /// messages (the `note_begin`/`note_end` envelope and the
197  /// malformed-line panic). Avoids the disk read when the model is
198  /// served from the binary's own embedded RelaxNG table.
199  pub fn load_compiled_schema_str(&mut self, content: &str, source: &str) {
200    note_begin(&s!("Loading compiled schema {}\n", source));
201    for line in content.lines() {
202      if let Some(caps) = TAG_MODEL_LINE_RE.captures(line) {
203        let tag = caps.get(1).map_or("", |m| m.as_str());
204        let attr = caps.get(2).map_or("", |m| m.as_str());
205        let children = caps.get(3).map_or("", |m| m.as_str());
206        self.add_tag_attribute(tag, attr.split(',').collect());
207        self.add_tag_content(tag, children.split(',').collect());
208      } else if let Some(caps) = CLASS_MODEL_LINE_RE.captures(line) {
209        let classname = caps.get(1).map_or("", |m| m.as_str());
210        let elements = caps.get(2).map_or("", |m| m.as_str());
211        let mut class_set = HashSet::default();
212        for set_element in elements.split(',') {
213          class_set.insert(arena::pin(set_element));
214        }
215        self.set_schema_class(classname, class_set);
216      } else if let Some(caps) = NAMESPACE_MODEL_LINE_RE.captures(line) {
217        let prefix = caps.get(1).map_or("", |m| m.as_str());
218        let namespace = caps.get(2).map_or("", |m| m.as_str());
219        self.register_document_namespace(prefix, Some(namespace));
220      } else {
221        panic!("Fatal:internal:{source} Compiled model '{source}' is malformatted at \"{line}\"");
222      }
223    }
224    note_end(&s!("Loading compiled schema {source}\n"));
225  }
226  pub fn add_tag_content(&mut self, tag: &str, elements: Vec<&str>) {
227    let frame = self.tagprop.entry(tag).or_default();
228
229    for element in elements {
230      frame.model.insert(arena::pin(element));
231    }
232  }
233
234  // pub fn get_tag_attributes(&self, tag: &str) -> Vec<&str> {
235  //   match self.tagprop.get(&arena::pin(tag)) {
236  //     Some(h) => {
237  //       let mut keys: Vec<&str> = h.attributes.iter().map(|s| arena::resolve(*s)).collect();
238  //       keys.sort_unstable();
239  //       keys
240  //     },
241  //     None => Vec::new(),
242  //   }
243  // }
244
245  pub fn add_tag_attribute(&mut self, tag: &str, attributes: Vec<&str>) {
246    let frame = self.tagprop.entry(tag).or_default();
247
248    for attribute in attributes {
249      frame.attributes.insert(arena::pin(attribute));
250    }
251  }
252
253  pub fn set_schema_class(&mut self, classname: &str, content: HashSet<SymStr>) {
254    self.schema_class.insert(classname, content);
255  }
256
257  /// Serialise the loaded schema into the `.model` plain-text format
258  /// emitted by Perl `LaTeXML::Common::Model::compileSchema`
259  /// (Model.pm L121-136). Three kinds of lines, all newline-separated:
260  ///
261  /// * `prefix=namespace` for every entry in `document_namespaces` (sorted by prefix).
262  /// * `classname:=(elt1,elt2,...)` for every entry in `schema_class` (sorted by classname; each
263  ///   element list sorted).
264  /// * `tag{attr1,attr2}(child1,child2)` for every entry in `tagprop` (sorted by tag; attrs and
265  ///   children sorted; tags whose name starts with `!` are skipped — they are content-model-only
266  ///   negations).
267  ///
268  /// Output is identical to the Perl tool so a downstream
269  /// `tools/compileschema.sh` can diff Rust vs. Perl-generated
270  /// `LaTeXML.model` files byte-for-byte (modulo schema content).
271  pub fn dump_compiled_schema(&self) -> String {
272    fn sym_to_string(sym: SymStr) -> String { arena::with(sym, |s| s.to_string()) }
273    fn syms_sorted(set: impl IntoIterator<Item = SymStr>) -> Vec<String> {
274      let mut v: Vec<String> = set.into_iter().map(sym_to_string).collect();
275      v.sort();
276      v
277    }
278    let mut out = String::new();
279    let prefixes = syms_sorted(self.document_namespaces.keys().copied());
280    for prefix in &prefixes {
281      let ns_opt = self
282        .document_namespaces
283        .get_sym(arena::pin(prefix.as_str()));
284      let ns = match ns_opt {
285        Some(v) => sym_to_string(*v),
286        None => continue,
287      };
288      out.push_str(prefix);
289      out.push('=');
290      out.push_str(&ns);
291      out.push('\n');
292    }
293    let classnames = syms_sorted(self.schema_class.keys().copied());
294    for classname in &classnames {
295      let elements = match self.schema_class.get_sym(arena::pin(classname.as_str())) {
296        Some(set) => set,
297        None => continue,
298      };
299      let elt_names = syms_sorted(elements.iter().copied());
300      out.push_str(classname);
301      out.push_str(":=(");
302      out.push_str(&elt_names.join(","));
303      out.push_str(")\n");
304    }
305    let tags = syms_sorted(self.tagprop.keys().copied());
306    for tag in &tags {
307      if tag.starts_with('!') {
308        continue;
309      }
310      let frame = match self.tagprop.get_sym(arena::pin(tag.as_str())) {
311        Some(f) => f,
312        None => continue,
313      };
314      let attrs = syms_sorted(frame.attributes.iter().copied());
315      let children = syms_sorted(frame.model.iter().copied());
316      out.push_str(tag);
317      out.push('{');
318      out.push_str(&attrs.join(","));
319      out.push_str("}(");
320      out.push_str(&children.join(","));
321      out.push_str(")\n");
322    }
323    out
324  }
325  pub fn describe_model(&self) {}
326  fn load_internal_extensions(&mut self) {
327    if !self.tagprop.contains_key("ltx:_CaptureBlock_") {
328      // Synthesize ltx:_CaptureBlock_ to act like the union of ltx:block,
329      // ltx:logical-block, ltx:sectional-block, Caption, FrontMatter,
330      // BackMatter (Perl Common/Model.pm loadInternalExtensions L96-97).
331      // FrontMatter/BackMatter were missing here, so a captured box that
332      // legitimately holds frontmatter/backmatter content was modelled
333      // more narrowly than Perl.
334      self.synthesize_element("ltx:_CaptureBlock_", &[
335        "ltx:block",
336        "ltx:logical-block",
337        "ltx:sectional-block",
338        "Caption",
339        "FrontMatter",
340        "BackMatter",
341      ]);
342      let cb_entry = self.tagprop.entry("ltx:_CaptureBlock_").or_default();
343      cb_entry.model.insert(pin!("svg:g"));
344      cb_entry.model.insert(pin!("svg:foreignObject"));
345    }
346  }
347
348  /// Clone the tagprop's (allowed content & attributes) of @other to $tag
349  fn synthesize_element(&mut self, tag: &str, others: &[&str]) {
350    let mut to_add_in_model = Vec::new();
351    let mut to_add_in_attrs = Vec::new();
352    for other in others {
353      if let Some(content) = self.schema_class.get(other) {
354        for child in content {
355          to_add_in_model.push(*child);
356        }
357      } else if let Some(entry) = self.tagprop.get(other) {
358        for child in &entry.model {
359          to_add_in_model.push(*child);
360        }
361        for attr in &entry.attributes {
362          to_add_in_attrs.push(*attr);
363        }
364      }
365    }
366    let capture = self.tagprop.entry(tag).or_default();
367    for child in to_add_in_model {
368      capture.model.insert(child);
369    }
370    for attr in to_add_in_attrs {
371      capture.attributes.insert(attr);
372    }
373  }
374}
375
376pub fn set_relaxng_schema(schema: &str) { model_mut!().set_relaxng_schema(schema) }
377pub fn add_schema_declaration(document: &mut Document) {
378  if let Some(ref schema) = model!().schema {
379    schema.add_schema_declaration(document);
380  }
381}
382
383pub fn load_schema(search_paths: &[&str]) -> Result<()> {
384  // Only load once
385  let mut model = model_mut!();
386  if model.schema.is_some() {
387    return Ok(());
388  }
389  let mut name = String::new();
390  if model.schema_data.is_none() {
391    // TODO: Return this code path to normal once we properly load schemas
392    Warn!("expected", "<model>", "TODO");
393    // Warn('expected', '<model>', undef, "No Schema Model has been declared; assuming LaTeXML");
394    // // article ??? or what ? undef gives problems!
395
396    model.register_document_namespace("ltx", Some(LTX_NAMESPACE));
397    model.set_relaxng_schema("LaTeXML");
398    model.register_namespace("ltx", Some(LTX_NAMESPACE));
399    model.register_namespace("svg", Some("http://www.w3.org/2000/svg"));
400    model.register_namespace("xlink", Some("http://www.w3.org/1999/xlink")); // Needed for SVG
401    model.register_namespace("m", Some("http://www.w3.org/1998/Math/MathML"));
402    model.register_namespace("xhtml", Some("http://www.w3.org/1999/xhtml"));
403    model.permissive = true;
404  } // Actually, they could have declared all sorts of Tags....
405  // Only RelaxNG schemas are supported (DTD support removed from Rust port)
406  if let Some(ref data) = model.schema_data {
407    if data[0] == pin!("RelaxNG") {
408      name = arena::to_string(data[1]);
409      model.schema = Some(Relaxng {
410        name: name.clone(),
411        ..Relaxng::default()
412      });
413    } else {
414      let message = arena::with(data[0], |schema_type_str| {
415        s!("Can't load a schema of type {schema_type_str:?}")
416      });
417      Error!("unknown", "schematype", message)
418    }
419  }
420
421  if !model.no_compiled && model.schema.is_some() {
422    let paths: Option<Vec<String>> = if search_paths.is_empty() {
423      None
424    } else {
425      Some(search_paths.iter().map(ToString::to_string).collect())
426    };
427    let pathname_opt = pathname::find(&name, pathname::PathnameFindOptions {
428      paths,
429      extensions: Some(vec![s!("model")]),
430      installation_subdir: Some(s!("resources/RelaxNG")),
431    });
432
433    match pathname_opt {
434      Some(compiled_path) => model.load_compiled_schema(&compiled_path),
435      None => {
436        // No `.model` on the searchpath. Try the embedded RelaxNG
437        // table for a same-named compiled schema first (the
438        // distribution path — binary running outside the source
439        // tree); fall through to raw `.rng` parsing if that misses
440        // too.
441        let embed_key = format!("{}.model", name);
442        if let Some(content) = crate::common::relaxng::embedded::lookup(&embed_key) {
443          model.load_compiled_schema_str(content, &format!("<embedded>/{embed_key}"));
444        } else {
445          let paths: Vec<&std::path::Path> =
446            search_paths.iter().map(std::path::Path::new).collect();
447          let schema = model.schema.as_mut().unwrap();
448          let schema_name = schema.name.clone();
449          if let Err(err) = schema.load_schema(&schema_name, &paths) {
450            let msg = format!("load_schema failed for {}: {}", schema_name, err);
451            Warn!("expected", "RelaxNG", msg);
452          }
453        }
454      },
455    };
456  }
457  model.load_internal_extensions();
458  if model.debug_mode {
459    model.describe_model()
460  }
461
462  Ok(())
463}
464
465pub fn get_document_namespace_prefix(
466  namespace: &str,
467  forattribute: bool,
468  probe: bool,
469) -> Option<SymStr> {
470  // Get the prefix associated with the namespace url, noting that for elements, it might by
471  // "#default", but for attributes would never be.
472  // log!("Searching for {:?} in {:?}", namespace, self.document_namespace_prefixes);
473  let mut docprefix = if !forattribute {
474    model!()
475      .document_namespace_prefixes
476      .get(&s!("DEFAULT#{namespace}"))
477      .copied()
478  } else {
479    None
480  };
481  let ns_sym = arena::pin(namespace);
482  if docprefix.is_none() {
483    docprefix = model!()
484      .document_namespace_prefixes
485      .get_sym(ns_sym)
486      .copied();
487  }
488
489  if docprefix.is_none() && !probe {
490    {
491      model_mut!().namespace_errors += 1;
492    }
493    let ns_err = s!("namespace{}", &model!().namespace_errors.to_string());
494    docprefix = Some(arena::pin(&ns_err));
495    {
496      model_mut!().register_document_namespace(&ns_err, Some(namespace));
497    }
498    let message2 = if let Some(dp) = docprefix {
499      arena::with(dp, |dp_str| s!("Using '{dp_str}' instead"))
500    } else {
501      String::from("No prefix to fall back on.")
502    };
503    Warn!(
504      "malformed",
505      namespace,
506      "No prefix has been registered for namespace (in document)",
507      message2
508    );
509  }
510  let default_sym = pin!("#default");
511  docprefix.filter(|p| p != &default_sym)
512}
513
514pub fn get_document_namespace(docprefix: &str, probe: bool) -> Option<String> {
515  let h_default_sym = pin!("#default");
516  let docprefix_sym = if docprefix.is_empty() {
517    h_default_sym
518  } else {
519    arena::pin(docprefix)
520  };
521  let ns_str = match model!().document_namespaces.get_sym(docprefix_sym) {
522    None => String::new(),
523    Some(sym) => arena::with(*sym, |s| {
524      if s.starts_with("DEFAULT#") {
525        s.replacen("DEFAULT#", "", 1)
526      } else {
527        s.to_string()
528      }
529    }),
530  };
531
532  if docprefix_sym != h_default_sym && ns_str.is_empty() && !probe {
533    {
534      model_mut!().namespace_errors += 1;
535    }
536    let ns_error = s!(
537      "http://example.com/namespace{}",
538      &model!().namespace_errors.to_string()
539    );
540    {
541      model_mut!().register_document_namespace(docprefix, Some(&ns_error));
542    }
543    let msg1 = arena::with(docprefix_sym, |dp_str| {
544      s!("No namespace has been registered for prefix '{dp_str}' (in document)")
545    });
546    let msg2 = s!("Using '{ns_str}' instead");
547    let err = || {
548      Error!("malformed", docprefix, msg1, msg2);
549      Ok(())
550    };
551    err().ok();
552  }
553  if ns_str.is_empty() {
554    None
555  } else {
556    Some(ns_str)
557  }
558}
559
560/// Get the (code) prefix associated with $namespace,
561/// creating a dummy prefix and signalling an error if none has been registered.
562///
563/// In the following:
564/// $forattribute is 1 if the namespace is for an attribute (in which case, there must be a
565/// non-empty prefix) $probe, if non 0, just test for namespace, without creating an entry
566/// if missing.
567pub fn get_namespace_prefix(namespace: &str, _forattribute: bool, probe: bool) -> Option<SymStr> {
568  let mut codeprefix: Option<SymStr> = None;
569  let ns_sym = arena::pin(namespace);
570  let mut model = model_mut!();
571  if !namespace.is_empty() {
572    codeprefix = model.code_namespace_prefixes.get_sym(ns_sym).copied();
573
574    if codeprefix.is_some() && !probe {
575      {
576        let docprefix = model.document_namespace_prefixes.get_sym(ns_sym);
577        // if there's a doc prefix and it's NOT already used in code namespace mapping
578        if docprefix.is_some() && !model.code_namespaces.contains_key_sym(docprefix.unwrap()) {
579          codeprefix = docprefix.copied();
580        }
581      }
582    } else {
583      // Else synthesize one
584      model.namespace_errors += 1;
585      let auto_prefix = arena::pin(s!("namespace{}", &model.namespace_errors.to_string()));
586      codeprefix = Some(auto_prefix);
587    }
588    model.register_namespace_sym(codeprefix.unwrap(), Some(arena::pin(namespace)));
589    // Warn!('malformed', $namespace, undef,
590    //   "No prefix has been registered for namespace '$namespace' (in code)",
591    //   "Using '$codeprefix' instead"); }
592  }
593
594  codeprefix
595}
596
597pub fn get_namespace(codeprefix: &str, probe: bool) -> Result<Option<SymStr>> {
598  let mut model = model_mut!();
599  let mut ns: Option<SymStr> = model.code_namespaces.get(codeprefix).copied();
600  if ns.is_none() && !probe {
601    model.namespace_errors += 1;
602    let example_namespace = s!(
603      "http://example.com/namespace{}",
604      &model.namespace_errors.to_string()
605    );
606    ns = Some(arena::pin(&example_namespace));
607    model.register_namespace(codeprefix, Some(&example_namespace));
608    Error!(
609      "malformed",
610      codeprefix,
611      s!("No namespace has been registered for prefix '{codeprefix}' (in code)"),
612      s!("Using '{example_namespace}' instead")
613    );
614  }
615  Ok(ns)
616}
617
618/// Get the node's qualified name in standard form
619/// Ie. using the registered (code) prefix for that namespace.
620/// NOTE: Reconsider how _Capture_ & _WildCard_ should be integrated!?!
621/// Build a `prefix:local` qname with one exact-capacity allocation, avoiding the
622/// `format!` machinery (`s!`) that showed up under `get_node_qname` in profiles —
623/// this runs on every model check / serialization step.
624#[inline]
625fn prefixed_qname(prefix: &str, local: &str) -> String {
626  let mut q = String::with_capacity(prefix.len() + 1 + local.len());
627  q.push_str(prefix);
628  q.push(':');
629  q.push_str(local);
630  q
631}
632
633pub fn get_node_qname(node: &Node) -> SymStr {
634  use libxml::tree::NodeType::*;
635  let node_type = node.get_type();
636  if node_type.is_none() {
637    return pin!("#BrokenNode");
638  }
639  // per-node hot path: the literal branches use the call-site-cached `pin!`
640  // (branch+load) rather than a per-call `pin_static` arena probe.
641  match node_type.unwrap() {
642    TextNode => pin!("#PCDATA"),
643    DocumentNode => pin!("#Document"),
644    CommentNode => pin!("#Comment"),
645    PiNode => pin!("#ProcessingInstruction"),
646    DTDNode => pin!("#DTD"),
647    NamespaceDecl => {
648      // match node.declared_uri() {
649      //   Some(ns) => match self.get_namespace_prefix(ns, false, true) {
650      //     Some(prefix) => s!("xmlns:")+prefix,
651      //     None => s!("xmlns")
652      //   },
653      //   None => s!("xmlns")
654      // }
655      pin!("xmlns")
656    },
657    ElementNode | AttributeNode => {
658      let name_str = node.get_name();
659      // Use the actual namespace prefix from the node when available.
660      // For SVG/MathML/etc with explicit prefix, use that prefix directly.
661      if let Some(ns) = node.get_namespace() {
662        let prefix = ns.get_prefix();
663        if prefix.is_empty() {
664          // Default namespace — use ltx: prefix. The document we build declares
665          // ltx as ITS default namespace, so an empty prefix means ltx here, and
666          // this deliberately does NOT read the namespace URI to confirm it:
667          // `Namespace::get_href` allocates a String per call, and on this path
668          // (~140 call sites, once per node and per attribute) that measured a
669          // 3.4% whole-conversion regression. A node whose DEFAULT namespace is
670          // something else can only come from a foreign document — see
671          // [`get_foreign_node_qname`], which is what reads such a tree.
672          arena::pin(prefixed_qname("ltx", &name_str))
673        } else {
674          // Explicit prefix (e.g., "svg", "m") — use it
675          arena::pin(prefixed_qname(&prefix, &name_str))
676        }
677      } else {
678        // No namespace — special cases for non-namespaced elements
679        match name_str.as_str() {
680          "song" | "verse" => arena::pin(name_str),
681          regular => arena::pin(prefixed_qname("ltx", regular)),
682        }
683      }
684    },
685    // Need others?
686    _ => {
687      // Defense-in-depth (issue #217): a node with an unexpected libxml2 type
688      // reached qname resolution. Degrade to the same `#BrokenNode` sentinel
689      // the `node_type.is_none()` arm above already returns, rather than the
690      // original hard `panic!`. LaTeXML never builds such nodes, so on a
691      // healthy tree this arm is unreachable; it exists only so a stray
692      // corrupt/foreign node can never crash qname resolution.
693      pin!("#BrokenNode")
694    },
695  }
696}
697
698/// [`get_node_qname`] for a node read out of a FOREIGN document — a tree parsed
699/// by [`crate::common::xml::parse_fragment`] rather than built by us. Used by
700/// `Document::append_tree`, the one place such a tree is ever walked.
701///
702/// The difference is confined to the DEFAULT-namespace case. Perl's single
703/// `Model::getNodeQName` always maps a namespace URI to its registered code
704/// prefix (`Common/Model.pm` → `getNamespacePrefix`), which is what gives
705/// `RegisterNamespace` (`Package.pm:2049`) its effect on absorbed content: an
706/// xhtml snippet arrives as `<p xmlns="…/1999/xhtml">`, i.e. an EMPTY libxml
707/// prefix over a non-ltx URI, and must be re-created as `xhtml:p`. Mislabelling
708/// it `ltx:p` would strip exactly the namespace the XHTML post-processor keys on
709/// (`copy-foreign` matches `xhtml:*`), silently dropping the raw HTML.
710///
711/// Splitting that off from `get_node_qname` is a pure PERFORMANCE factoring with
712/// no behavioural difference, because inside our own document an element either
713/// sits in the ltx default namespace or carries an explicit code prefix — the
714/// re-created `xhtml:p` above included. So only a foreign tree can present the
715/// ambiguous shape, and reading the URI on the shared path would cost every
716/// other caller a `String` allocation per node (measured: 3.4%).
717pub fn get_foreign_node_qname(node: &Node) -> SymStr {
718  if let Some(ns) = node.get_namespace()
719    && ns.get_prefix().is_empty()
720  {
721    let href = ns.get_href();
722    if !href.is_empty() && href != LTX_NAMESPACE {
723      // `.copied()` yields an owned SymStr, so the model guard does not outlive
724      // this expression (no borrow held across the arena pins below).
725      let registered = model!()
726        .code_namespace_prefixes
727        .get_sym(arena::pin(href.as_str()))
728        .copied();
729      // An UNREGISTERED URI keeps the historical ltx fallback, i.e. whatever
730      // `get_node_qname` would have said.
731      if let Some(code_prefix) = registered {
732        let name_str = node.get_name();
733        return arena::pin(arena::with(code_prefix, |p| prefixed_qname(p, &name_str)));
734      }
735    }
736  }
737  get_node_qname(node)
738}
739
740/// Borrow a node's qualified name (`ltx:section`, `#PCDATA`, …) as a `&str`
741/// for the duration of `caller`.
742///
743/// The closure form is the point: qnames are interned, and lending the arena's
744/// copy lets a caller compare or match on the name without the `String` that
745/// [`get_node_qname`] + `to_string` would allocate on every node of a walk.
746pub fn with_node_qname<R, FnR>(node: &Node, caller: FnR) -> R
747where FnR: FnOnce(&str) -> R {
748  let qsym = get_node_qname(node);
749  arena::with(qsym, |qname_str| caller(qname_str))
750}
751
752/// Same as get_node_qname, but using the Document namespace prefixes
753pub fn get_node_document_qname(node: &Node) -> SymStr {
754  use libxml::tree::NodeType::*;
755  let node_type = node.get_type();
756  if node_type.is_none() {
757    return pin!("#BrokenNode");
758  }
759
760  match node_type.unwrap() {
761    TextNode => pin!("#PCDATA"),
762    DocumentNode => pin!("#Document"),
763    CommentNode => pin!("#Comment"),
764    PiNode => pin!("#ProcessingInstruction"),
765    DTDNode => pin!("#DTD"),
766
767    // TODO
768    // elsif ($type == XML_NAMESPACE_DECL) {
769    //   my $ns = $node->declaredURI;
770    //   my $prefix = $ns && $self->getDocumentNamespacePrefix($ns, 0, 1);
771    //   return ($prefix ? 'xmlns:' . $prefix : 'xmlns'); }
772    NamespaceDecl => pin!("xmlns"),
773
774    ElementNode | AttributeNode => {
775      let empty_sym = pin!("");
776      let mut prefix = empty_sym;
777      if let Some(ns) = node.get_namespace() {
778        let href = ns.get_href();
779        if !href.is_empty() {
780          prefix = get_document_namespace_prefix(&href, false, true).unwrap_or(empty_sym);
781        }
782      }
783      if prefix == empty_sym {
784        arena::pin(node.get_name())
785      } else {
786        arena::pin(arena::with(prefix, |prefix_str| {
787          s!("{}:{}", prefix_str, node.get_name())
788        }))
789      }
790    },
791    // Need others?
792    t => {
793      panic!("Fatal:misdefined:<caller> should not ask for qualified name for node of type {t:?}")
794    },
795  }
796}
797
798/// Read a possibly-PREFIXED attribute off a node, resolving the prefix the same
799/// way `Document::set_attribute` does on the write side (via [`decode_qname`]).
800///
801/// libxml stores `xml:id` as local name `id` in the built-in xml namespace, and
802/// `xmlGetProp` matches on the plain name — so `get_attribute("xml:id")` finds
803/// NOTHING. That asymmetry is invisible in Rust bindings (which read ids through
804/// `get_attribute_ns`) but bites any caller that writes an attribute by qualified
805/// name and then tries to read it back by the same name, which is exactly what a
806/// script does after `generateID`.
807pub fn get_node_attribute(node: &Node, key: &str) -> Option<String> {
808  if let Some(ns_uri) = attribute_namespace(key) {
809    let local = key.split_once(':').map_or(key, |(_, l)| l);
810    if let Some(value) = node.get_attribute_ns(local, &ns_uri) {
811      return Some(value);
812    }
813  }
814  node.get_attribute(key)
815}
816
817/// Remove a possibly-PREFIXED attribute — the counterpart of
818/// [`get_node_attribute`], with the same namespace resolution.
819pub fn remove_node_attribute(node: &mut Node, key: &str) -> std::result::Result<(), String> {
820  if let Some(ns_uri) = attribute_namespace(key) {
821    let local = key.split_once(':').map_or(key, |(_, l)| l).to_string();
822    if node.get_attribute_ns(&local, &ns_uri).is_some() {
823      return node
824        .remove_attribute_ns(&local, &ns_uri)
825        .map_err(|e| e.to_string());
826    }
827  }
828  node.remove_attribute(key).map_err(|e| e.to_string())
829}
830
831/// The namespace URI an attribute name resolves to, or `None` when it is
832/// unprefixed (or its prefix is not registered). `xml:` is built in — the one
833/// prefix [`decode_qname`] deliberately hands back whole for libxml to special-case.
834fn attribute_namespace(key: &str) -> Option<String> {
835  let (prefix, _) = key.split_once(':')?;
836  if prefix == "xml" {
837    return Some(XML_NS.to_string());
838  }
839  match decode_qname(key) {
840    Ok((ns_uri, _local)) => ns_uri,
841    Err(_) => None,
842  }
843}
844
845/// Given a Qualified name, possibly prefixed with a namespace prefix,
846/// as defined by the code namespace mapping,
847/// return the NamespaceURI and localname.
848pub fn decode_qname(codetag: &str) -> Result<(Option<String>, String)> {
849  match PREFIXED_LOCALNAME_RE.captures(codetag) {
850    Some(captures) => {
851      let prefix = captures.get(1).map_or("", |m| m.as_str());
852      let localname = captures.get(2).map_or("", |m| m.as_str());
853
854      if prefix == "xml" {
855        Ok((None, codetag.to_string()))
856      } else {
857        Ok((
858          get_namespace(prefix, false)?.map(arena::to_string),
859          localname.to_string(),
860        ))
861      }
862    },
863    None => Ok((None, codetag.to_string())),
864  }
865}
866
867/// TODO: We need a proper data model to deal with the Symbol - String distinction.
868/// For now let's allocate strings and release the arena, but this is a CODE SMELL!
869pub fn decode_qname_sym(sym: SymStr) -> Result<(Option<String>, String)> {
870  let codetag = arena::to_string(sym);
871  decode_qname(&codetag)
872}
873
874//**********************************************************************
875// Document Structure Queries
876//**********************************************************************
877// NOTE: These are public, but perhaps should be passed
878// to submodel, in case it can evolve to more precision?
879// However, it would need more context to do that.
880
881/// A check for allowed direct element containment, using ticket-based `SymStr` names.
882///
883/// TODO: This is a major code smell, experimental prototyping to see how to interoperate
884/// strings with the inerned arena.
885/// `can_contain` and `can_contain_sym` should be implemented once, and one should be an
886/// interning-only helper.
887pub fn can_contain_sym(tag: SymStr, child: SymStr) -> bool {
888  // Handle obvious cases explicitly.
889  if tag == pin!("#PCDATA") || tag == pin!("#Comment") || tag == pin!("") {
890    return false;
891  } else if tag == pin!("_WildCard_") {
892    return true;
893  };
894  if arena::with(tag, |tag_str| tag_str.ends_with("_Capture_"))
895    || arena::with(child, |child_str| {
896      child_str.ends_with("_Capture_") || child_str.ends_with("_CaptureBlock_")
897    })
898  {
899    // with or without namespace prefix
900    return true;
901  }
902
903  if child == pin!("_WildCard_")
904    || child == pin!("#Comment")
905    || child == pin!("#ProcessingInstruction")
906    || child == pin!("#DTD")
907  {
908    return true;
909  }
910
911  let mut model = model_mut!();
912  if model.permissive && tag == pin!("#Document") && child != pin!("#PCDATA") {
913    return true; // No schema? Punt!
914  }
915
916  // Else query tag properties.
917  let model_entry = &mut model.tagprop.entry_sym(tag).or_default().model;
918  model_entry.contains(&pin!("ANY")) || model_entry.contains(&child)
919}
920
921/// Can an element with (qualified name) `tag` contain a `child` element?
922pub fn can_contain(tag: &str, child: &str) -> bool {
923  // Handle obvious cases explicitly.
924  match tag {
925    "#PCDATA" | "#Comment" | "" => return false,
926    "_WildCard_" => return true,
927    _ => {},
928  };
929  if tag.ends_with("_Capture_") || child.ends_with("_Capture_") || tag.ends_with("_CaptureBlock_") {
930    // with or without namespace prefix
931    return true;
932  }
933
934  match child {
935    "_WildCard_" | "#Comment" | "#ProcessingInstruction" | "#DTD" => return true,
936    _ => {},
937  };
938  let state_model = model!();
939  if state_model.permissive && tag == "#Document" && child != "#PCDATA" {
940    return true; // No schema? Punt!
941  }
942
943  // Else query tag properties, falling back to the `ns:*` wildcard entry when this
944  // exact tag has none of its own, then applying the same most-specific-first
945  // chain as the attribute test — both are Perl `Common/Model.pm`. Without the
946  // fallback a wildcard content model such as `ltx:rawhtml{}(xhtml:*)` would
947  // reject every concrete `xhtml:p` absorbed into it.
948  let frame = state_model.tagprop.get(tag);
949  let wildcard_frame = if frame.is_none() {
950    namespace_wildcard_tag(tag).and_then(|w| state_model.tagprop.get(&w))
951  } else {
952    None
953  };
954  let content = &frame
955    .or(wildcard_frame)
956    .unwrap_or(&*DEFAULT_TAG_FRAME)
957    .model;
958  content.contains(&pin!("ANY")) || set_allows(content, child)
959}
960
961/// Perl `Model::canContain`/`canHaveAttribute` fall back to the NAMESPACE
962/// WILDCARD entry when a prefixed tag has no `tagprop` entry of its own
963/// (`Common/Model.pm`: `if (!$model && ($tag =~ /^(\w*):/)) { $xtag = $1 . ':*' }`).
964/// This is how a wildcard schema element such as `xhtml:*` — the content model of
965/// `ltx:rawhtml` — governs every concrete `xhtml:p`/`xhtml:b` absorbed into it.
966///
967/// Two knowingly narrower details than the Perl regex, neither reachable with the
968/// schema we compile (checked against `resources/RelaxNG/LaTeXML.model`, whose
969/// only `ns:*` entries are `*:*` and `xhtml:*`):
970/// * Perl falls back when the tag has no entry OR its entry has no `model` /
971///   `attributes` key; a Rust `TagFrame` always has both, possibly empty, so the
972///   callers fall back only when the whole frame is missing. Reaching that
973///   difference needs a schema with BOTH an empty-set entry and a matching `ns:*`
974///   entry — e.g. an `ltx:*`, which no LaTeXML schema declares.
975/// * Perl's `\w*` does not match `*`, so it never derives a wildcard from a tag
976///   that is already one; `split_once` would, but only for the `*:*` entry, which
977///   maps to itself and is looked up only when it has no frame — and it has one.
978fn namespace_wildcard_tag(tag: &str) -> Option<String> {
979  tag.split_once(':').map(|(ns, _)| s!("{ns}:*"))
980}
981
982/// Perl's most-specific-first membership chain over a `tagprop` set — shared by
983/// `canContain`'s child test and `canHaveAttribute`'s attribute test
984/// (`Common/Model.pm`). Order: exact, `!exact`, then for a PREFIXED key the
985/// namespace wildcard `ns:*` / `!ns:*` and finally `*:*` / `!*:*`; for an
986/// unprefixed key, `*` / `!*`. A `!`-prefixed entry is an explicit exclusion and
987/// always beats the broader wildcard that follows it. The compiled model really
988/// does carry these (e.g. `ltx:XMText{!aria:*,!xml:*,*:*,…}`), so an exact-match-only
989/// test both over-rejects (ignoring `*:*`) and under-rejects (ignoring `!ns:*`).
990fn set_allows(set: &HashSet<SymStr>, key: &str) -> bool {
991  if set.contains(&arena::pin(key)) {
992    return true;
993  }
994  if set.is_empty() {
995    return false; // nothing can match; skip the probe allocations
996  }
997  if set.contains(&arena::pin(s!("!{key}"))) {
998    return false;
999  }
1000  match key.split_once(':') {
1001    Some((ns, _)) => {
1002      if set.contains(&arena::pin(s!("{ns}:*"))) {
1003        return true;
1004      }
1005      if set.contains(&arena::pin(s!("!{ns}:*"))) {
1006        return false;
1007      }
1008      if set.contains(&pin!("!*:*")) {
1009        return false;
1010      }
1011      set.contains(&pin!("*:*"))
1012    },
1013    None => {
1014      if set.contains(&pin!("!*")) {
1015        return false;
1016      }
1017      set.contains(&pin!("*"))
1018    },
1019  }
1020}
1021
1022pub fn can_have_attribute(tag: SymStr, attrib: SymStr) -> bool {
1023  // Handle obvious cases explicitly.
1024  if let Some(early_choice) = arena::with(tag, |tag_str| match tag_str {
1025    "#PCDATA" | "#Comment" | "#Document" | "#ProcessingInstruction" | "#DTD" => Some(false),
1026    "_WildCard_" => Some(true),
1027    other if other.ends_with("_Capture_") => Some(true),
1028    _ => None,
1029  }) {
1030    return early_choice;
1031  };
1032  // Perl: `return 1 if $attrib =~ /^_/;` — internal bookkeeping attributes are
1033  // always permitted, whatever the schema says about the element.
1034  if arena::with(attrib, |a| a.starts_with('_')) {
1035    return true;
1036  }
1037  let model = model!();
1038  if model.permissive {
1039    return true;
1040  }
1041
1042  // Else query tag properties, falling back to the `ns:*` wildcard entry when
1043  // this exact tag has none of its own (Perl canHaveAttribute).
1044  let frame = model.tagprop.get_sym(tag);
1045  let wildcard_frame = if frame.is_none() {
1046    arena::with(tag, namespace_wildcard_tag).and_then(|w| model.tagprop.get(&w))
1047  } else {
1048    None
1049  };
1050  let attributes = &frame
1051    .or(wildcard_frame)
1052    .unwrap_or(&*DEFAULT_TAG_FRAME)
1053    .attributes;
1054  arena::with(attrib, |a| set_allows(attributes, a))
1055}
1056
1057pub fn is_node_in_schema_class(class_name: &str, tag: &Node) -> bool {
1058  let tag = get_node_qname(tag);
1059  is_in_schema_class(arena::pin(class_name), tag)
1060}
1061pub fn is_in_schema_class(class_name: SymStr, tag: SymStr) -> bool {
1062  match model!().schema_class.get_sym(class_name) {
1063    Some(class) => class.contains(&tag),
1064    _ => false,
1065  }
1066}
1067
1068//**********************************************************************
1069// Accessors
1070//**********************************************************************
1071
1072pub fn get_tags() -> Vec<SymStr> { model!().tagprop.keys().copied().collect() }
1073
1074pub fn get_tag_contents(tag: SymStr) -> Vec<SymStr> {
1075  match model!().tagprop.get_sym(tag) {
1076    Some(h) => h.model.iter().copied().collect(),
1077    None => Vec::new(),
1078  }
1079}
1080pub fn set_model(new_model: Model) {
1081  let mut model = model_mut!();
1082  *model = new_model;
1083}
1084pub fn is_permissive() -> bool { model!().permissive }
1085
1086pub fn with_schema_data<FnR, R>(caller: FnR) -> R
1087where FnR: FnOnce(Option<&Vec<SymStr>>) -> R {
1088  caller(model!().schema_data.as_ref())
1089}
1090pub fn set_schema(schema: Relaxng) {
1091  let mut model = model_mut!();
1092  model.schema = Some(schema);
1093}
1094pub fn set_schema_class(classname: &str, content: HashSet<SymStr>) {
1095  model_mut!().set_schema_class(classname, content)
1096}
1097pub fn add_tag_content(tag: &str, elements: Vec<&str>) {
1098  model_mut!().add_tag_content(tag, elements)
1099}
1100pub fn add_tag_attribute(tag: &str, attributes: Vec<&str>) {
1101  model_mut!().add_tag_attribute(tag, attributes)
1102}
1103
1104pub(crate) fn compute_indirect_model_aux(
1105  tag: SymStr,
1106  start_opt: Option<SymStr>,
1107  desirability: usize,
1108  openability: &mut SymHashMap<u32>,
1109  desc: &mut SymHashMap<SymHashMap<usize>>,
1110) {
1111  let start = match start_opt {
1112    Some(s) => s,
1113    None => pin!(""),
1114  };
1115
1116  // A bit tricky here, we need to release the model_mut!() borrow immediately, which is why we
1117  // move ownership of the tag strings into the tag_contents vector.
1118  // That leads to a bunch of .clone()s later one, but stays close to the original algorithm
1119  let tag_contents: Vec<SymStr> = get_tag_contents(tag);
1120
1121  for kid in tag_contents {
1122    // Memoise on (kid, start) to bound recursion in cyclic schemas, but
1123    // retain the *maximum* desirability observed across paths — the
1124    // outer loop in compute_indirect_model picks the highest-scoring
1125    // starting tag, so the score stored here must reflect the best path,
1126    // not the first one the hashmap iteration happened to surface.
1127    //
1128    // The prior "first visit wins" behavior (WISDOM #49) caused paralists
1129    // test-harness runs to assign `desc[#PCDATA][ltx:text] = 50` when
1130    // `contents(text)` iterated `ltx:picture` before `#PCDATA`: the
1131    // sub-recursion `text → picture → #PCDATA` inserted 50 first and the
1132    // direct `text → #PCDATA` path was skipped, forcing the auto-open
1133    // path to pick `<ltx:picture>` instead of `<ltx:text>`.
1134    let prior = desc.entry_sym(kid).or_default().get_sym(start).copied();
1135    if let Some(prior_d) = prior
1136      && prior_d >= desirability
1137    {
1138      continue;
1139    }
1140
1141    if start != pin!("") {
1142      desc
1143        .entry_sym(kid)
1144        .or_default()
1145        .insert_sym(start, desirability);
1146    }
1147
1148    if kid != pin!("#PCDATA")
1149      && let Some(priority) = openability.get_sym(kid).copied()
1150    {
1151      let inner = if start != pin!("") { start } else { kid };
1152      // Perl Document.pm L220: `$desirability * $x`. We keep integer
1153      // arithmetic (priorities scaled by 100), so this is a scaled multiply.
1154      let next_desirability = desirability * (priority as usize) / 100;
1155      compute_indirect_model_aux(kid, Some(inner), next_desirability, openability, desc);
1156    }
1157  }
1158}
1159/// Bind an OUTPUT-document prefix to a namespace URI, on the current model.
1160///
1161/// The document half of the two mappings described on
1162/// [`Model::register_namespace`] — these are the prefixes that appear in the
1163/// generated XML, and the one place `#default` is meaningful (for unprefixed
1164/// elements; never for attributes). Passing `None` unbinds the prefix.
1165pub fn register_document_namespace(docprefix: &str, namespace_opt: Option<&str>) {
1166  model_mut!().register_document_namespace(docprefix, namespace_opt)
1167}
1168
1169/// Returns all registered document namespace prefixes and their URIs.
1170pub fn get_document_namespace_prefixes() -> Vec<(String, String)> {
1171  model!()
1172    .document_namespace_prefixes
1173    .iter()
1174    .map(|(ns_sym, prefix_sym)| {
1175      let prefix = arena::with(*prefix_sym, |s| s.to_string());
1176      let ns = arena::with(*ns_sym, |s| s.to_string());
1177      (prefix, ns)
1178    })
1179    .collect()
1180}
1181
1182/// Bind a CODE prefix to a namespace URI, on the current model.
1183///
1184/// The coding half of the two mappings described on
1185/// [`Model::register_namespace`] — the prefixes constructors and bindings write,
1186/// which must be one-to-one and admit no default namespace. Passing `None`
1187/// unbinds the prefix.
1188pub fn register_namespace(codeprefix: &str, namespace_opt: Option<&str>) {
1189  model_mut!().register_namespace(codeprefix, namespace_opt)
1190}
1191
1192pub fn with_code_namespaces<FnR, R>(caller: FnR) -> R
1193where FnR: FnOnce(&SymHashMap<SymStr>) -> R {
1194  caller(&model!().code_namespaces)
1195}
1196
1197#[cfg(test)]
1198mod wildcard_resolution_tests {
1199  //! Direct coverage for the Perl `Common/Model.pm` resolution helpers shared by
1200  //! `canContain` (child test) and `canHaveAttribute` (attribute test). The
1201  //! end-to-end script-bindings guard only exercises the plain `*` branch via
1202  //! `xhtml:*`, so the negation and namespace-wildcard branches are pinned here.
1203  use super::*;
1204
1205  fn set_of(keys: &[&str]) -> HashSet<SymStr> { keys.iter().map(|k| arena::pin(*k)).collect() }
1206
1207  #[test]
1208  fn namespace_wildcard_tag_only_applies_to_prefixed_tags() {
1209    assert_eq!(
1210      namespace_wildcard_tag("xhtml:p").as_deref(),
1211      Some("xhtml:*")
1212    );
1213    assert_eq!(namespace_wildcard_tag("ltx:para").as_deref(), Some("ltx:*"));
1214    // Unprefixed names have no namespace wildcard to fall back on.
1215    assert_eq!(namespace_wildcard_tag("para"), None);
1216    assert_eq!(namespace_wildcard_tag("#PCDATA"), None);
1217  }
1218
1219  #[test]
1220  fn exact_membership_wins_and_empty_sets_reject() {
1221    let s = set_of(&["class", "href"]);
1222    assert!(set_allows(&s, "class"));
1223    assert!(!set_allows(&s, "style"));
1224    assert!(!set_allows(&HashSet::default(), "class"));
1225  }
1226
1227  #[test]
1228  fn a_negation_beats_the_wildcard_that_would_otherwise_allow_it() {
1229    // Perl order: exact, !exact, ns:*, !ns:*, !*:*, *:*  — the `!` entries are
1230    // explicit exclusions and must beat the broader wildcard that follows them.
1231    // This is the real shape of a compiled entry, e.g.
1232    // `ltx:XMText{!aria:*,!xml:*,*:*,about,…}`.
1233    let s = set_of(&["!aria:*", "!xml:*", "*:*", "about"]);
1234    assert!(set_allows(&s, "about"), "exact entry allows");
1235    assert!(
1236      set_allows(&s, "data:foo"),
1237      "*:* allows an unexcluded namespace"
1238    );
1239    assert!(
1240      !set_allows(&s, "aria:label"),
1241      "!aria:* excludes its namespace"
1242    );
1243    assert!(!set_allows(&s, "xml:lang"), "!xml:* excludes its namespace");
1244
1245    // An exact exclusion beats a namespace wildcard that would allow it.
1246    let s2 = set_of(&["!svg:width", "svg:*"]);
1247    assert!(set_allows(&s2, "svg:height"));
1248    assert!(!set_allows(&s2, "svg:width"));
1249
1250    // `!*:*` denies every namespaced key that is not exactly listed.
1251    let s3 = set_of(&["!*:*", "svg:width"]);
1252    assert!(set_allows(&s3, "svg:width"));
1253    assert!(!set_allows(&s3, "svg:height"));
1254  }
1255
1256  #[test]
1257  fn unprefixed_keys_use_the_bare_star_not_the_namespaced_one() {
1258    // `*:*` governs PREFIXED keys only; an unprefixed `class` needs `*`.
1259    let namespaced_only = set_of(&["*:*"]);
1260    assert!(!set_allows(&namespaced_only, "class"));
1261    assert!(set_allows(&namespaced_only, "svg:width"));
1262
1263    // This is the entry that lets attributes survive on absorbed xhtml markup:
1264    // the compiled model carries `xhtml:*{*,*:*}`.
1265    let html_wildcard = set_of(&["*", "*:*"]);
1266    assert!(set_allows(&html_wildcard, "class"));
1267    assert!(set_allows(&html_wildcard, "xlink:href"));
1268
1269    // …and `!*` denies the unprefixed ones.
1270    let denied = set_of(&["!*", "*:*"]);
1271    assert!(!set_allows(&denied, "class"));
1272    assert!(set_allows(&denied, "svg:width"));
1273  }
1274}