Skip to main content

latexml_core/common/relaxng/
scan.rs

1//! RelaxNG XML → AST scanner.
2//!
3//! Port of `RelaxNG.pm` lines 100–390. The Perl original is a recursive
4//! visitor over a `LibXML::Document`; this is a recursive visitor over
5//! `libxml::tree::Node`.
6//!
7//! The scanner is **side-effect-free with respect to the AST**: it
8//! returns a `Vec<Pattern>` per node and never mutates `Relaxng`'s
9//! definition tables. The only pieces it touches on `Relaxng` are
10//! `internal_grammars` (a fresh counter for embedded `<grammar>`
11//! blocks) and `document_namespaces` (driven by `xmlns:` declarations
12//! on RelaxNG nodes — analogous to `Model::registerDocumentNamespace`
13//! in Perl). Definition recording, "Used by" graph, element tables —
14//! all happen during [`super::simplify`].
15//!
16//! The matching style intentionally mirrors `getRelaxOp` + the
17//! `$relaxop eq 'rng:foo'` cascade in Perl, so a reader who knows the
18//! original code finds the same shape here.
19
20use std::path::{Path, PathBuf};
21
22use libxml::{
23  parser::Parser as XmlParser,
24  readonly::RoNode,
25  tree::{Document as XmlDocument, NodeType},
26};
27
28use super::{CombineOp, DefCombiner, Pattern, Relaxng};
29
30/// RelaxNG namespace URI.
31const RNG_NS: &str = "http://relaxng.org/ns/structure/1.0";
32/// Compatibility-annotations namespace URI (carries `<a:documentation>`).
33const RNGA_NS: &str = "http://relaxng.org/ns/compatibility/annotations/1.0";
34
35/// Errors a scan can produce.
36#[derive(Debug)]
37pub enum ScanError {
38  /// The named .rng file could not be located on `search_paths`.
39  FileNotFound(String),
40  /// libxml could not parse the .rng file.
41  Parse(String),
42  /// A non-fatal "unrecognised RelaxNG construct" warning escalated to
43  /// an error (we collect these and continue, but the caller may want
44  /// to inspect the list afterward).
45  UnknownOp { op: String, file: PathBuf },
46}
47
48impl std::fmt::Display for ScanError {
49  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50    match self {
51      ScanError::FileNotFound(name) => write!(f, "RelaxNG file not found: {}", name),
52      ScanError::Parse(msg) => write!(f, "RelaxNG parse error: {}", msg),
53      ScanError::UnknownOp { op, file } => {
54        write!(f, "Unknown RelaxNG op '{}' in {}", op, file.display())
55      },
56    }
57  }
58}
59
60impl std::error::Error for ScanError {}
61
62// ----- public entry points ------------------------------------------------
63
64/// Locate, parse, and scan a single RelaxNG schema file.
65///
66/// Wraps the result in `Pattern::Module` whose `name` is the file's
67/// basename without extension — matches `scanExternal` in Perl.
68///
69/// Tries the disk-side `find_file` first (developer overrides, system
70/// installs); if nothing is on the searchpath, falls through to the
71/// compile-time embedded RelaxNG table and parses those bytes
72/// directly via `XmlParser::parse_string`. No filesystem extraction
73/// is involved on the embed branch — the prebuilt binary can run
74/// `--validate` / `--schemadocs` against in-`.rodata` schemas.
75pub fn scan_external(
76  rng: &mut Relaxng,
77  name: &str,
78  inherit_ns: Option<&str>,
79  search_paths: &[&Path],
80) -> Result<Vec<Pattern>, ScanError> {
81  let parser = XmlParser::default();
82  // Disk-then-embedded resolution, shared with the `rng:include` handler (#538).
83  let (path, xml_doc): (PathBuf, XmlDocument) = resolve_schema_ref(&parser, name, search_paths)?;
84  let root = xml_doc
85    .get_root_readonly()
86    .ok_or_else(|| ScanError::Parse("empty document".into()))?;
87
88  // Collect namespace declarations on the root for downstream qname
89  // resolution. (LaTeXML's encodeQName-equivalent — but simpler: we
90  // just record the prefix→URI map.)
91  collect_namespaces(rng, root);
92
93  // First-call-wins: capture the master grammar's `ns="…"` URI as
94  // the schema's primary namespace. Recursive scan_external calls
95  // (for `<externalRef>` etc.) don't overwrite this — they're
96  // satellite modules whose ns may differ from the entry-point.
97  if rng.primary_namespace.is_none()
98    && let Some(uri) = root.get_attribute("ns")
99    && !uri.is_empty()
100  {
101    rng.primary_namespace = Some(uri);
102  }
103
104  let modname = strip_rng_ext(name);
105  let mut new_paths: Vec<&Path> = Vec::with_capacity(search_paths.len() + 1);
106  let dir = path.parent().unwrap_or_else(|| Path::new("."));
107  new_paths.push(dir);
108  new_paths.extend(search_paths);
109
110  // The scanner takes the search-paths slice via context so includes
111  // resolve relative to *this* file's directory first.
112  let mut ctx = ScanContext { search_paths: new_paths };
113  let body = scan_pattern(rng, root, inherit_ns, &mut ctx)?;
114  Ok(vec![Pattern::Module { name: modname, body }])
115}
116
117/// Scan an already-parsed RelaxNG XML root in-memory. Useful for unit
118/// tests that pass small RNG fragments inline.
119pub fn scan_string(rng: &mut Relaxng, xml: &str) -> Result<Vec<Pattern>, ScanError> {
120  let parser = XmlParser::default();
121  let xml_doc = parser
122    .parse_string(xml)
123    .map_err(|e| ScanError::Parse(format!("{:?}", e)))?;
124  let root = xml_doc
125    .get_root_readonly()
126    .ok_or_else(|| ScanError::Parse("empty document".into()))?;
127  collect_namespaces(rng, root);
128  let mut ctx = ScanContext { search_paths: Vec::new() };
129  scan_pattern(rng, root, None, &mut ctx)
130}
131
132// ----- internal recursion -------------------------------------------------
133
134struct ScanContext<'p> {
135  search_paths: Vec<&'p Path>,
136}
137
138/// Compute the RelaxNG op identifier for `node`, e.g. `"rng:element"`.
139/// Returns `None` for non-element nodes or for elements outside the
140/// RelaxNG / compatibility-annotations namespaces.
141fn get_relax_op(node: RoNode) -> Option<String> {
142  if node.get_type() != Some(NodeType::ElementNode) {
143    return None;
144  }
145  let local = node.get_name();
146  let ns_uri = node
147    .get_namespace()
148    .map(|ns| ns.get_href())
149    .unwrap_or_default();
150  let prefix = match ns_uri.as_str() {
151    RNG_NS => "rng",
152    RNGA_NS => "rnga",
153    "" => return None,
154    other => return Some(format!("{{{}}}:{}", other, local)),
155  };
156  Some(format!("{}:{}", prefix, local))
157}
158
159/// Element-only children of `node` (filters out text nodes and
160/// comments). Mirrors `getElements` in Perl.
161fn get_elements(node: RoNode) -> Vec<RoNode> {
162  let mut out = Vec::new();
163  let mut child = node.get_first_child();
164  while let Some(c) = child {
165    if c.get_type() == Some(NodeType::ElementNode) {
166      out.push(c);
167    }
168    child = c.get_next_sibling();
169  }
170  out
171}
172
173/// Map a RelaxNG combiner localname (`group`/`interleave`/…) to
174/// [`CombineOp`].
175fn combine_op_from_localname(name: &str) -> Option<CombineOp> {
176  Some(match name {
177    "group" => CombineOp::Group,
178    "interleave" => CombineOp::Interleave,
179    "choice" => CombineOp::Choice,
180    "optional" => CombineOp::Optional,
181    "zeroOrMore" => CombineOp::ZeroOrMore,
182    "oneOrMore" => CombineOp::OneOrMore,
183    "list" => CombineOp::List,
184    _ => return None,
185  })
186}
187
188/// Encode `(ns?, local)` as the `prefix:local` qname Perl
189/// `Model::encodeQName` produces. With no namespace, returns `local`
190/// unchanged. For URIs without a non-empty prefix mapping yet,
191/// synthesises a fresh `namespace<N>` prefix and registers it (mirrors
192/// LaTeXML's `getDocumentNamespacePrefix(...)` auto-assignment).
193fn encode_qname(rng: &mut Relaxng, ns: Option<&str>, local: &str) -> String {
194  match ns {
195    None | Some("") => local.to_string(),
196    Some(uri) => format!("{}:{}", ensure_prefix(rng, uri), local),
197  }
198}
199
200fn ensure_prefix(rng: &mut Relaxng, uri: &str) -> String {
201  // Perl `getNamespacePrefix` order: the model's CODE prefix wins. Seeded from
202  // `code_namespace_prefixes` before the scan, this maps e.g.
203  // `http://dlmf.nist.gov/LaTeXML` → `ltx` (registered by `base_schema`), so a
204  // namespace referenced only as a default `ns=` — with no `xmlns:` declaration
205  // in the schema — resolves to the conventional prefix the rest of the engine
206  // uses, not a synthetic `namespaceN` that the runtime would never match (#652).
207  if let Some(prefix) = rng.code_namespace_prefixes.get(uri) {
208    return prefix.clone();
209  }
210  // Else a schema-local `xmlns:` prefix for this URI (Perl: document prefix).
211  if let Some((prefix, _)) = rng
212    .document_namespaces
213    .iter()
214    .find(|(p, u)| !p.is_empty() && u.as_str() == uri)
215  {
216    return prefix.clone();
217  }
218  let n = rng
219    .document_namespaces
220    .keys()
221    .filter(|p| p.starts_with("namespace"))
222    .count()
223    + 1;
224  let new_prefix = format!("namespace{}", n);
225  rng
226    .document_namespaces
227    .insert(new_prefix.clone(), uri.to_string());
228  new_prefix
229}
230
231/// Walk a single RelaxNG pattern node. `inherit_ns` is the namespace
232/// that scope around `node` would assign to unqualified names (the
233/// nearest enclosing `ns="..."` attribute).
234fn scan_pattern(
235  rng: &mut Relaxng,
236  node: RoNode,
237  inherit_ns: Option<&str>,
238  ctx: &mut ScanContext<'_>,
239) -> Result<Vec<Pattern>, ScanError> {
240  let Some(op) = get_relax_op(node) else {
241    return Ok(Vec::new());
242  };
243  let ns = node
244    .get_attribute("ns")
245    .or_else(|| inherit_ns.map(String::from));
246  let ns_ref = ns.as_deref();
247
248  match op.as_str() {
249    "rng:element" => scan_pattern_element(rng, ns_ref, node, ctx),
250    "rng:attribute" => scan_pattern_attribute(rng, ns_ref, node, ctx),
251    "rng:mixed" => {
252      let mut body = vec![Pattern::Text];
253      body.extend(scan_children(rng, ns_ref, get_elements(node), ctx)?);
254      Ok(vec![Pattern::Combination {
255        op: CombineOp::Interleave,
256        body,
257      }])
258    },
259    "rng:ref" => Ok(vec![Pattern::Ref {
260      qname: node.get_attribute("name").unwrap_or_default(),
261    }]),
262    "rng:parentRef" => Ok(vec![Pattern::ParentRef {
263      qname: node.get_attribute("name").unwrap_or_default(),
264    }]),
265    "rng:empty" | "rng:notAllowed" => Ok(Vec::new()),
266    "rng:text" => Ok(vec![Pattern::Text]),
267    "rng:value" => Ok(vec![Pattern::Value(node.get_content())]),
268    "rng:data" => Ok(vec![Pattern::Data(
269      node.get_attribute("type").unwrap_or_default(),
270    )]),
271    "rng:externalRef" => {
272      let href = node.get_attribute("href").unwrap_or_default();
273      let paths: Vec<&Path> = ctx.search_paths.clone();
274      scan_external(rng, &href, ns_ref, &paths)
275    },
276    "rng:grammar" => {
277      rng.internal_grammars += 1;
278      let name = format!("grammar{}", rng.internal_grammars);
279      let body = scan_grammar_content(rng, ns_ref, get_elements(node), ctx)?;
280      Ok(vec![Pattern::Grammar { name, body }])
281    },
282    "rnga:documentation" => {
283      let text = node.get_content();
284      Ok(vec![Pattern::Doc(text)])
285    },
286    other => {
287      // Combiners (group/interleave/choice/optional/zeroOrMore/
288      // oneOrMore/list).
289      if let Some(stripped) = other.strip_prefix("rng:")
290        && let Some(cop) = combine_op_from_localname(stripped)
291      {
292        let body = scan_children(rng, ns_ref, get_elements(node), ctx)?;
293        return Ok(vec![Pattern::Combination { op: cop, body }]);
294      }
295      // Unknown — Perl warns and returns empty; we do the same.
296      Ok(Vec::new())
297    },
298  }
299}
300
301fn scan_pattern_element(
302  rng: &mut Relaxng,
303  ns: Option<&str>,
304  node: RoNode,
305  ctx: &mut ScanContext<'_>,
306) -> Result<Vec<Pattern>, ScanError> {
307  let mut children = get_elements(node);
308  if let Some(name) = node.get_attribute("name") {
309    let body = scan_children(rng, ns, children, ctx)?;
310    Ok(vec![Pattern::Element {
311      name: encode_qname(rng, ns, &name),
312      body,
313    }])
314  } else if !children.is_empty() {
315    let name_node = children.remove(0);
316    let names = scan_name_class(rng, name_node, false, ns);
317    let body_proto = scan_children(rng, ns, children, ctx)?;
318    Ok(
319      names
320        .into_iter()
321        .map(|n| Pattern::Element {
322          name: n,
323          body: body_proto.clone(),
324        })
325        .collect(),
326    )
327  } else {
328    Ok(Vec::new())
329  }
330}
331
332fn scan_pattern_attribute(
333  rng: &mut Relaxng,
334  ns: Option<&str>,
335  node: RoNode,
336  ctx: &mut ScanContext<'_>,
337) -> Result<Vec<Pattern>, ScanError> {
338  let xns = node.get_attribute("ns"); // EXPLICIT only (no inherit)
339  let xns_ref = xns.as_deref();
340  let mut children = get_elements(node);
341  if let Some(name) = node.get_attribute("name") {
342    let body = scan_children(rng, ns, children, ctx)?;
343    Ok(vec![Pattern::Attribute {
344      name: encode_qname(rng, xns_ref, &name),
345      body,
346    }])
347  } else if !children.is_empty() {
348    let name_node = children.remove(0);
349    let names = scan_name_class(rng, name_node, true, ns);
350    let body_proto = scan_children(rng, ns, children, ctx)?;
351    Ok(
352      names
353        .into_iter()
354        .map(|n| Pattern::Attribute {
355          name: n,
356          body: body_proto.clone(),
357        })
358        .collect(),
359    )
360  } else {
361    Ok(Vec::new())
362  }
363}
364
365fn scan_children(
366  rng: &mut Relaxng,
367  ns: Option<&str>,
368  children: Vec<RoNode>,
369  ctx: &mut ScanContext<'_>,
370) -> Result<Vec<Pattern>, ScanError> {
371  let mut out = Vec::new();
372  for child in children {
373    out.extend(scan_pattern(rng, child, ns, ctx)?);
374  }
375  Ok(out)
376}
377
378fn scan_grammar_content(
379  rng: &mut Relaxng,
380  ns: Option<&str>,
381  content: Vec<RoNode>,
382  ctx: &mut ScanContext<'_>,
383) -> Result<Vec<Pattern>, ScanError> {
384  let mut out = Vec::new();
385  for node in content {
386    out.extend(scan_grammar_item(rng, node, ns, ctx)?);
387  }
388  Ok(out)
389}
390
391fn scan_grammar_item(
392  rng: &mut Relaxng,
393  node: RoNode,
394  inherit_ns: Option<&str>,
395  ctx: &mut ScanContext<'_>,
396) -> Result<Vec<Pattern>, ScanError> {
397  let Some(op) = get_relax_op(node) else {
398    return Ok(Vec::new());
399  };
400  let children = get_elements(node);
401  let ns = node
402    .get_attribute("ns")
403    .or_else(|| inherit_ns.map(String::from));
404  let ns_ref = ns.as_deref();
405
406  match op.as_str() {
407    "rng:start" => {
408      let body = scan_children(rng, ns_ref, children, ctx)?;
409      Ok(vec![Pattern::Start { body }])
410    },
411    "rng:define" => {
412      let name = node.get_attribute("name").unwrap_or_default();
413      let combiner = match node.get_attribute("combine").as_deref() {
414        Some("choice") => DefCombiner::Choice,
415        Some("interleave") => DefCombiner::Interleave,
416        _ => DefCombiner::Group,
417      };
418      let body = scan_children(rng, ns_ref, children, ctx)?;
419      Ok(vec![Pattern::Def { combiner, name, body }])
420    },
421    "rng:div" => scan_grammar_content(rng, ns_ref, children, ctx),
422    "rng:include" => {
423      let href = node.get_attribute("href").unwrap_or_default();
424      let paths: Vec<&Path> = ctx.search_paths.clone();
425      // Resolve disk-then-embedded, exactly as `scan_external` does — so a
426      // `urn:x-LaTeXML:RelaxNG:` include resolves from the embedded table when
427      // `resources/RelaxNG/` is not on disk (installed binary) (#538).
428      let (path, xml_doc) = resolve_schema_ref(&XmlParser::default(), &href, &paths)?;
429      let inner_root = xml_doc
430        .get_root_readonly()
431        .ok_or_else(|| ScanError::Parse("empty include".into()))?;
432      collect_namespaces(rng, inner_root);
433      // Push the included file's directory to the search path so its
434      // own includes resolve correctly.
435      let dir = path.parent().unwrap_or_else(|| Path::new("."));
436      let mut nested_paths: Vec<&Path> = Vec::with_capacity(ctx.search_paths.len() + 1);
437      nested_paths.push(dir);
438      nested_paths.extend(&ctx.search_paths);
439      let mut nested_ctx = ScanContext { search_paths: nested_paths };
440
441      // Ignore the outer <grammar>, if any (`<include>` doesn't establish
442      // a binding in RelaxNG).
443      let patterns = if get_relax_op(inner_root).as_deref() == Some("rng:grammar") {
444        let nns = inner_root
445          .get_attribute("ns")
446          .or_else(|| inherit_ns.map(String::from));
447        scan_grammar_content(
448          rng,
449          nns.as_deref(),
450          get_elements(inner_root),
451          &mut nested_ctx,
452        )?
453      } else {
454        scan_pattern(rng, inner_root, None, &mut nested_ctx)?
455      };
456
457      let modname = strip_rng_ext(&href);
458      let module = Pattern::Module { name: modname, body: patterns };
459      let replacements = scan_grammar_content(rng, ns_ref, children, ctx)?;
460      if replacements.is_empty() {
461        Ok(vec![module])
462      } else {
463        Ok(vec![Pattern::Override {
464          module: Box::new(module),
465          replacements,
466        }])
467      }
468    },
469    _ => Ok(Vec::new()),
470  }
471}
472
473/// Walk a `<name>`/`<anyName>`/`<nsName>`/`<choice>`/`<except>` name-class.
474/// Returns the qnames covered, exclusions appearing as `!qname` per the
475/// Perl convention.
476fn scan_name_class(
477  rng: &mut Relaxng,
478  node: RoNode,
479  for_attr: bool,
480  ns: Option<&str>,
481) -> Vec<String> {
482  let Some(op) = get_relax_op(node) else {
483    return Vec::new();
484  };
485  match op.as_str() {
486    "rng:name" => {
487      let raw = node.get_content();
488      let (decns, local) = decode_qname(rng, &raw);
489      let effective_ns = decns.as_deref().or(ns);
490      let resolved_ns = if for_attr { None } else { effective_ns };
491      vec![encode_qname(rng, resolved_ns, &local)]
492    },
493    "rng:anyName" => {
494      let except: Vec<String> = get_elements(node)
495        .into_iter()
496        .flat_map(|c| scan_name_class(rng, c, for_attr, ns))
497        .collect();
498      let mut all = vec!["*".to_string(), "*:*".to_string()];
499      all.extend(except);
500      filter_names(all)
501    },
502    "rng:nsName" => {
503      let xns = node.get_attribute("ns").or_else(|| ns.map(String::from));
504      let star = encode_qname(rng, xns.as_deref(), "*");
505      let except: Vec<String> = get_elements(node)
506        .into_iter()
507        .flat_map(|c| scan_name_class(rng, c, for_attr, ns))
508        .collect();
509      let mut all = vec![star];
510      all.extend(except);
511      filter_names(all)
512    },
513    "rng:choice" => {
514      let mut names = std::collections::BTreeSet::new();
515      let mut child = node.get_first_child();
516      while let Some(c) = child {
517        for n in scan_name_class(rng, c, for_attr, ns) {
518          names.insert(n);
519        }
520        child = c.get_next_sibling();
521      }
522      names.into_iter().collect()
523    },
524    "rng:except" => {
525      let mut names = std::collections::BTreeSet::new();
526      for c in get_elements(node) {
527        for n in scan_name_class(rng, c, for_attr, ns) {
528          names.insert(n);
529        }
530      }
531      names.into_iter().map(|n| format!("!{}", n)).collect()
532    },
533    _ => Vec::new(),
534  }
535}
536
537/// Collapse `(*:*, !*:*)` etc. — drops exclusions that cancel an
538/// inclusion. Perl `filterNames`.
539fn filter_names(names: Vec<String>) -> Vec<String> {
540  use std::collections::BTreeMap;
541  let mut include: BTreeMap<String, String> = BTreeMap::new();
542  let mut exclude: BTreeMap<String, String> = BTreeMap::new();
543  for n in names {
544    if let Some(rest) = n.strip_prefix('!') {
545      exclude.insert(n.clone(), rest.to_string());
546    } else {
547      include.insert(n.clone(), n);
548    }
549  }
550  let drop_keys: Vec<String> = exclude
551    .iter()
552    .filter(|(_, target)| include.contains_key(target.as_str()))
553    .map(|(k, _)| k.clone())
554    .collect();
555  for k in drop_keys {
556    if let Some(target) = exclude.remove(&k) {
557      include.remove(&target);
558    }
559  }
560  include
561    .keys()
562    .cloned()
563    .chain(exclude.keys().cloned())
564    .collect()
565}
566
567/// Split a `prefix:local` token into `(ns_uri?, local)` using the
568/// schema's namespace map. Mirrors Perl's `decodeQName`.
569fn decode_qname(rng: &Relaxng, raw: &str) -> (Option<String>, String) {
570  match raw.split_once(':') {
571    Some((prefix, local)) => match rng.document_namespaces.get(prefix) {
572      Some(uri) => (Some(uri.clone()), local.to_string()),
573      None => (None, raw.to_string()),
574    },
575    None => (None, raw.to_string()),
576  }
577}
578
579// ----- helpers ------------------------------------------------------------
580
581fn collect_namespaces(rng: &mut Relaxng, root: RoNode) {
582  for ns in root.get_namespace_declarations() {
583    let prefix = ns.get_prefix();
584    let href = ns.get_href();
585    if href.starts_with("http://relaxng.org") {
586      continue;
587    }
588    // If the schema author bound a different prefix to this URI, drop
589    // any pre-seeded conventional binding so the schema's choice wins.
590    if !prefix.is_empty() {
591      rng
592        .document_namespaces
593        .retain(|p, u| p == &prefix || u != &href);
594    }
595    rng.document_namespaces.insert(prefix, href);
596  }
597}
598
599fn strip_rng_ext(name: &str) -> String {
600  name
601    .strip_suffix(".rng")
602    .or_else(|| name.strip_suffix(".rnc"))
603    .unwrap_or(name)
604    .to_string()
605}
606
607/// Resolve a schema reference. Honours LaTeXML's `urn:x-LaTeXML:RelaxNG:`
608/// URN scheme: strips the prefix, then translates remaining `:`
609/// separators into path separators so e.g.
610/// `urn:x-LaTeXML:RelaxNG:svg:svg11.rng` → `svg/svg11.rng` lookup.
611///
612/// Resolve a RelaxNG schema reference — a filename or a `urn:x-LaTeXML:RelaxNG:`
613/// URN — to a parsed document. Disk first (via [`find_file`]), then the embedded
614/// table ([`super::embedded::lookup`]). Mirrors Perl's `RelaxNG->new`
615/// (`Common/XML/RelaxNG.pm:28-51`), which BOTH `scanExternal` and the `rng:include`
616/// handler call — so an `<include>` and an `<externalRef>` resolve identically
617/// (#538; before, only `scan_external` consulted the embedded table). Perl
618/// appends `.rng` when absent (`RelaxNG.pm:33`); we do the same for the embedded
619/// key so `urn:x-LaTeXML:RelaxNG:LaTeXML` (no extension) resolves.
620///
621/// The URN is stripped to a bare relative path in Rust and the embedded lookup
622/// keys on that — a `urn:` is never handed to libxml2 for URI composition, so the
623/// resolution is identical on macOS and Linux (whose libxml2 differ there).
624fn resolve_schema_ref(
625  parser: &XmlParser,
626  name: &str,
627  search_paths: &[&Path],
628) -> Result<(PathBuf, XmlDocument), ScanError> {
629  if let Some(p) = find_file(name, search_paths) {
630    let doc = parser
631      .parse_file(p.to_str().unwrap_or(""))
632      .map_err(|e| ScanError::Parse(format!("{:?}", e)))?;
633    return Ok((p, doc));
634  }
635  // Not on disk — fall back to the embedded table. Strip the URN scheme the same
636  // way `find_file` does so the key matches what `build.rs` recorded under
637  // `resources/RelaxNG/`, and append `.rng` when absent (Perl `RelaxNG.pm:33`).
638  let mut embed_key = match name.strip_prefix("urn:x-LaTeXML:RelaxNG:") {
639    Some(rest) => rest.replace(':', "/"),
640    None => name.to_string(),
641  };
642  if !embed_key.ends_with(".rng") {
643    embed_key.push_str(".rng");
644  }
645  let bytes =
646    super::embedded::lookup(&embed_key).ok_or_else(|| ScanError::FileNotFound(name.to_string()))?;
647  let doc = parser
648    .parse_string(bytes)
649    .map_err(|e| ScanError::Parse(format!("{:?}", e)))?;
650  Ok((PathBuf::from(&embed_key), doc))
651}
652
653/// Disk-only — embedded schemas are handled by [`resolve_schema_ref`], which
654/// consults the [`super::embedded::lookup`] table when `find_file` returns
655/// `None`. Keeping this function disk-only means developer-tree edits and
656/// system-installed schemas continue to win over the bundled copies.
657fn find_file(name: &str, search_paths: &[&Path]) -> Option<PathBuf> {
658  let bare = match name.strip_prefix("urn:x-LaTeXML:RelaxNG:") {
659    Some(rest) => rest.replace(':', "/"),
660    None => name.to_string(),
661  };
662  // A schema is normally referenced by its bare name (`RelaxNGSchema('MySchema')`),
663  // so try both the name as given and `<name>.rng` — Perl `RelaxNG.pm:33` appends
664  // `.rng` when absent, and the embedded fallback (`resolve_schema_ref`) does too;
665  // disk lookup must match, or a bare name never resolves on the `--path`
666  // SEARCHPATHS (dginev/latexml-oxide#652).
667  let mut candidates: Vec<String> = vec![bare.clone()];
668  if !bare.ends_with(".rng") {
669    candidates.push(format!("{bare}.rng"));
670  }
671  for candidate in &candidates {
672    let asis = Path::new(candidate);
673    if asis.is_file() {
674      return Some(asis.to_path_buf());
675    }
676    // Search EVERY provided path (the `--path` SEARCHPATHS), in order.
677    for dir in search_paths {
678      let joined = dir.join(candidate);
679      if joined.is_file() {
680        return Some(joined);
681      }
682    }
683  }
684  None
685}
686
687// ----- unit tests ---------------------------------------------------------
688
689#[cfg(test)]
690mod tests {
691  use super::*;
692  use crate::common::relaxng::Pattern;
693
694  fn matches_combination(pat: &Pattern, op: CombineOp) -> bool {
695    matches!(pat, Pattern::Combination { op: o, .. } if *o == op)
696  }
697
698  #[test]
699  fn scan_empty_grammar() {
700    let xml = r#"<grammar xmlns="http://relaxng.org/ns/structure/1.0"></grammar>"#;
701    let mut rng = Relaxng::default();
702    let patterns = scan_string(&mut rng, xml).expect("scan");
703    assert_eq!(patterns.len(), 1);
704    match &patterns[0] {
705      Pattern::Grammar { name, body } => {
706        assert_eq!(name, "grammar1");
707        assert!(body.is_empty());
708      },
709      other => panic!("expected Grammar, got {:?}", other),
710    }
711  }
712
713  #[test]
714  fn scan_simple_element() {
715    let xml = r#"
716      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
717        <start><element name="root"><empty/></element></start>
718      </grammar>
719    "#;
720    let mut rng = Relaxng::default();
721    let patterns = scan_string(&mut rng, xml).expect("scan");
722    let body = match &patterns[0] {
723      Pattern::Grammar { body, .. } => body,
724      other => panic!("expected Grammar, got {:?}", other),
725    };
726    let start_body = match &body[0] {
727      Pattern::Start { body } => body,
728      other => panic!("expected Start, got {:?}", other),
729    };
730    match &start_body[0] {
731      Pattern::Element { name, body: _ } => assert_eq!(name, "root"),
732      other => panic!("expected Element, got {:?}", other),
733    }
734  }
735
736  #[test]
737  fn scan_choice_combinator() {
738    let xml = r#"
739      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
740        <start>
741          <choice>
742            <element name="a"><empty/></element>
743            <element name="b"><empty/></element>
744          </choice>
745        </start>
746      </grammar>
747    "#;
748    let mut rng = Relaxng::default();
749    let patterns = scan_string(&mut rng, xml).expect("scan");
750    let body = match &patterns[0] {
751      Pattern::Grammar { body, .. } => body,
752      _ => unreachable!(),
753    };
754    let start_body = match &body[0] {
755      Pattern::Start { body } => body,
756      _ => unreachable!(),
757    };
758    assert!(matches_combination(&start_body[0], CombineOp::Choice));
759  }
760
761  /// #538: a `<include href="urn:x-LaTeXML:RelaxNG:…">` must resolve from the
762  /// embedded schema table when the file is not on disk (the installed-binary
763  /// path — `resources/RelaxNG/` is absent, and `scan_string` uses empty search
764  /// paths). Before the fix the `rng:include` arm used disk-only `find_file` and
765  /// failed `FileNotFound`, while `externalRef`/`scan_external` had the embedded
766  /// fallback — an asymmetry Perl doesn't have (both go through `RelaxNG->new`).
767  #[test]
768  fn rng_include_resolves_urn_via_embedded() {
769    // Extension form (as written in the bundled LaTeXML.rng).
770    let xml = r#"
771      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
772        <include href="urn:x-LaTeXML:RelaxNG:LaTeXML-common.rng"/>
773      </grammar>
774    "#;
775    let mut rng = Relaxng::default();
776    scan_string(&mut rng, xml)
777      .expect("urn include (with .rng) should resolve from the embedded table");
778
779    // No-extension form (the issue title: `urn:x-LaTeXML:RelaxNG:LaTeXML`).
780    // Perl appends `.rng` (Common/XML/RelaxNG.pm:33), so this must resolve too.
781    let xml_noext = r#"
782      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
783        <include href="urn:x-LaTeXML:RelaxNG:LaTeXML-common"/>
784      </grammar>
785    "#;
786    let mut rng2 = Relaxng::default();
787    scan_string(&mut rng2, xml_noext)
788      .expect("no-extension urn include should resolve (.rng appended)");
789  }
790
791  #[test]
792  fn scan_define_with_combine_choice() {
793    let xml = r#"
794      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
795        <define name="X"><element name="x1"><empty/></element></define>
796        <define name="X" combine="choice"><element name="x2"><empty/></element></define>
797      </grammar>
798    "#;
799    let mut rng = Relaxng::default();
800    let patterns = scan_string(&mut rng, xml).expect("scan");
801    let body = match &patterns[0] {
802      Pattern::Grammar { body, .. } => body,
803      _ => unreachable!(),
804    };
805    assert_eq!(body.len(), 2);
806    match &body[0] {
807      Pattern::Def {
808        combiner: DefCombiner::Group,
809        name,
810        ..
811      } => assert_eq!(name, "X"),
812      other => panic!("expected Def(Group), got {:?}", other),
813    }
814    match &body[1] {
815      Pattern::Def {
816        combiner: DefCombiner::Choice,
817        name,
818        ..
819      } => assert_eq!(name, "X"),
820      other => panic!("expected Def(Choice), got {:?}", other),
821    }
822  }
823
824  #[test]
825  fn scan_attribute_with_value() {
826    let xml = r#"
827      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
828        <start>
829          <element name="root">
830            <attribute name="kind"><value>sample</value></attribute>
831          </element>
832        </start>
833      </grammar>
834    "#;
835    let mut rng = Relaxng::default();
836    let patterns = scan_string(&mut rng, xml).expect("scan");
837    // descend Grammar → Start → Element → Attribute → Value
838    let attr_body = match &patterns[0] {
839      Pattern::Grammar { body, .. } => match &body[0] {
840        Pattern::Start { body: sb } => match &sb[0] {
841          Pattern::Element { body: eb, .. } => match &eb[0] {
842            Pattern::Attribute { body: ab, .. } => ab.clone(),
843            _ => unreachable!(),
844          },
845          _ => unreachable!(),
846        },
847        _ => unreachable!(),
848      },
849      _ => unreachable!(),
850    };
851    match &attr_body[0] {
852      Pattern::Value(v) => assert_eq!(v, "sample"),
853      other => panic!("expected Value, got {:?}", other),
854    }
855  }
856
857  #[test]
858  fn scan_documentation_annotation() {
859    let xml = r#"
860      <grammar
861        xmlns="http://relaxng.org/ns/structure/1.0"
862        xmlns:a="http://relaxng.org/ns/compatibility/annotations/1.0">
863        <define name="X">
864          <a:documentation>An example pattern</a:documentation>
865          <element name="x"><empty/></element>
866        </define>
867      </grammar>
868    "#;
869    let mut rng = Relaxng::default();
870    let patterns = scan_string(&mut rng, xml).expect("scan");
871    let body = match &patterns[0] {
872      Pattern::Grammar { body, .. } => body,
873      _ => unreachable!(),
874    };
875    let def_body = match &body[0] {
876      Pattern::Def { body, .. } => body,
877      _ => unreachable!(),
878    };
879    match &def_body[0] {
880      Pattern::Doc(s) => assert_eq!(s, "An example pattern"),
881      other => panic!("expected Doc, got {:?}", other),
882    }
883  }
884
885  #[test]
886  fn scan_mixed_normalises_to_interleave_with_text() {
887    let xml = r#"
888      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
889        <start>
890          <mixed><element name="b"><empty/></element></mixed>
891        </start>
892      </grammar>
893    "#;
894    let mut rng = Relaxng::default();
895    let patterns = scan_string(&mut rng, xml).expect("scan");
896    let inner = match &patterns[0] {
897      Pattern::Grammar { body, .. } => match &body[0] {
898        Pattern::Start { body } => match &body[0] {
899          Pattern::Combination { op, body } => (op, body.clone()),
900          _ => unreachable!(),
901        },
902        _ => unreachable!(),
903      },
904      _ => unreachable!(),
905    };
906    assert_eq!(*inner.0, CombineOp::Interleave);
907    assert!(matches!(inner.1[0], Pattern::Text));
908  }
909
910  #[test]
911  fn filter_names_drops_canceling_exclusion() {
912    let names = vec!["x".into(), "y".into(), "!y".into()];
913    let result = filter_names(names);
914    assert_eq!(result, vec!["x".to_string()]);
915  }
916
917  #[test]
918  fn filter_names_preserves_uncanceled_exclusion() {
919    let names = vec!["x".into(), "!z".into()];
920    let result = filter_names(names);
921    assert_eq!(result, vec!["x".to_string(), "!z".to_string()]);
922  }
923}