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  if let Some((prefix, _)) = rng
202    .document_namespaces
203    .iter()
204    .find(|(p, u)| !p.is_empty() && u.as_str() == uri)
205  {
206    return prefix.clone();
207  }
208  let n = rng
209    .document_namespaces
210    .keys()
211    .filter(|p| p.starts_with("namespace"))
212    .count()
213    + 1;
214  let new_prefix = format!("namespace{}", n);
215  rng
216    .document_namespaces
217    .insert(new_prefix.clone(), uri.to_string());
218  new_prefix
219}
220
221/// Walk a single RelaxNG pattern node. `inherit_ns` is the namespace
222/// that scope around `node` would assign to unqualified names (the
223/// nearest enclosing `ns="..."` attribute).
224fn scan_pattern(
225  rng: &mut Relaxng,
226  node: RoNode,
227  inherit_ns: Option<&str>,
228  ctx: &mut ScanContext<'_>,
229) -> Result<Vec<Pattern>, ScanError> {
230  let Some(op) = get_relax_op(node) else {
231    return Ok(Vec::new());
232  };
233  let ns = node
234    .get_attribute("ns")
235    .or_else(|| inherit_ns.map(String::from));
236  let ns_ref = ns.as_deref();
237
238  match op.as_str() {
239    "rng:element" => scan_pattern_element(rng, ns_ref, node, ctx),
240    "rng:attribute" => scan_pattern_attribute(rng, ns_ref, node, ctx),
241    "rng:mixed" => {
242      let mut body = vec![Pattern::Text];
243      body.extend(scan_children(rng, ns_ref, get_elements(node), ctx)?);
244      Ok(vec![Pattern::Combination {
245        op: CombineOp::Interleave,
246        body,
247      }])
248    },
249    "rng:ref" => Ok(vec![Pattern::Ref {
250      qname: node.get_attribute("name").unwrap_or_default(),
251    }]),
252    "rng:parentRef" => Ok(vec![Pattern::ParentRef {
253      qname: node.get_attribute("name").unwrap_or_default(),
254    }]),
255    "rng:empty" | "rng:notAllowed" => Ok(Vec::new()),
256    "rng:text" => Ok(vec![Pattern::Text]),
257    "rng:value" => Ok(vec![Pattern::Value(node.get_content())]),
258    "rng:data" => Ok(vec![Pattern::Data(
259      node.get_attribute("type").unwrap_or_default(),
260    )]),
261    "rng:externalRef" => {
262      let href = node.get_attribute("href").unwrap_or_default();
263      let paths: Vec<&Path> = ctx.search_paths.clone();
264      scan_external(rng, &href, ns_ref, &paths)
265    },
266    "rng:grammar" => {
267      rng.internal_grammars += 1;
268      let name = format!("grammar{}", rng.internal_grammars);
269      let body = scan_grammar_content(rng, ns_ref, get_elements(node), ctx)?;
270      Ok(vec![Pattern::Grammar { name, body }])
271    },
272    "rnga:documentation" => {
273      let text = node.get_content();
274      Ok(vec![Pattern::Doc(text)])
275    },
276    other => {
277      // Combiners (group/interleave/choice/optional/zeroOrMore/
278      // oneOrMore/list).
279      if let Some(stripped) = other.strip_prefix("rng:")
280        && let Some(cop) = combine_op_from_localname(stripped)
281      {
282        let body = scan_children(rng, ns_ref, get_elements(node), ctx)?;
283        return Ok(vec![Pattern::Combination { op: cop, body }]);
284      }
285      // Unknown — Perl warns and returns empty; we do the same.
286      Ok(Vec::new())
287    },
288  }
289}
290
291fn scan_pattern_element(
292  rng: &mut Relaxng,
293  ns: Option<&str>,
294  node: RoNode,
295  ctx: &mut ScanContext<'_>,
296) -> Result<Vec<Pattern>, ScanError> {
297  let mut children = get_elements(node);
298  if let Some(name) = node.get_attribute("name") {
299    let body = scan_children(rng, ns, children, ctx)?;
300    Ok(vec![Pattern::Element {
301      name: encode_qname(rng, ns, &name),
302      body,
303    }])
304  } else if !children.is_empty() {
305    let name_node = children.remove(0);
306    let names = scan_name_class(rng, name_node, false, ns);
307    let body_proto = scan_children(rng, ns, children, ctx)?;
308    Ok(
309      names
310        .into_iter()
311        .map(|n| Pattern::Element {
312          name: n,
313          body: body_proto.clone(),
314        })
315        .collect(),
316    )
317  } else {
318    Ok(Vec::new())
319  }
320}
321
322fn scan_pattern_attribute(
323  rng: &mut Relaxng,
324  ns: Option<&str>,
325  node: RoNode,
326  ctx: &mut ScanContext<'_>,
327) -> Result<Vec<Pattern>, ScanError> {
328  let xns = node.get_attribute("ns"); // EXPLICIT only (no inherit)
329  let xns_ref = xns.as_deref();
330  let mut children = get_elements(node);
331  if let Some(name) = node.get_attribute("name") {
332    let body = scan_children(rng, ns, children, ctx)?;
333    Ok(vec![Pattern::Attribute {
334      name: encode_qname(rng, xns_ref, &name),
335      body,
336    }])
337  } else if !children.is_empty() {
338    let name_node = children.remove(0);
339    let names = scan_name_class(rng, name_node, true, ns);
340    let body_proto = scan_children(rng, ns, children, ctx)?;
341    Ok(
342      names
343        .into_iter()
344        .map(|n| Pattern::Attribute {
345          name: n,
346          body: body_proto.clone(),
347        })
348        .collect(),
349    )
350  } else {
351    Ok(Vec::new())
352  }
353}
354
355fn scan_children(
356  rng: &mut Relaxng,
357  ns: Option<&str>,
358  children: Vec<RoNode>,
359  ctx: &mut ScanContext<'_>,
360) -> Result<Vec<Pattern>, ScanError> {
361  let mut out = Vec::new();
362  for child in children {
363    out.extend(scan_pattern(rng, child, ns, ctx)?);
364  }
365  Ok(out)
366}
367
368fn scan_grammar_content(
369  rng: &mut Relaxng,
370  ns: Option<&str>,
371  content: Vec<RoNode>,
372  ctx: &mut ScanContext<'_>,
373) -> Result<Vec<Pattern>, ScanError> {
374  let mut out = Vec::new();
375  for node in content {
376    out.extend(scan_grammar_item(rng, node, ns, ctx)?);
377  }
378  Ok(out)
379}
380
381fn scan_grammar_item(
382  rng: &mut Relaxng,
383  node: RoNode,
384  inherit_ns: Option<&str>,
385  ctx: &mut ScanContext<'_>,
386) -> Result<Vec<Pattern>, ScanError> {
387  let Some(op) = get_relax_op(node) else {
388    return Ok(Vec::new());
389  };
390  let children = get_elements(node);
391  let ns = node
392    .get_attribute("ns")
393    .or_else(|| inherit_ns.map(String::from));
394  let ns_ref = ns.as_deref();
395
396  match op.as_str() {
397    "rng:start" => {
398      let body = scan_children(rng, ns_ref, children, ctx)?;
399      Ok(vec![Pattern::Start { body }])
400    },
401    "rng:define" => {
402      let name = node.get_attribute("name").unwrap_or_default();
403      let combiner = match node.get_attribute("combine").as_deref() {
404        Some("choice") => DefCombiner::Choice,
405        Some("interleave") => DefCombiner::Interleave,
406        _ => DefCombiner::Group,
407      };
408      let body = scan_children(rng, ns_ref, children, ctx)?;
409      Ok(vec![Pattern::Def { combiner, name, body }])
410    },
411    "rng:div" => scan_grammar_content(rng, ns_ref, children, ctx),
412    "rng:include" => {
413      let href = node.get_attribute("href").unwrap_or_default();
414      let paths: Vec<&Path> = ctx.search_paths.clone();
415      // Resolve disk-then-embedded, exactly as `scan_external` does — so a
416      // `urn:x-LaTeXML:RelaxNG:` include resolves from the embedded table when
417      // `resources/RelaxNG/` is not on disk (installed binary) (#538).
418      let (path, xml_doc) = resolve_schema_ref(&XmlParser::default(), &href, &paths)?;
419      let inner_root = xml_doc
420        .get_root_readonly()
421        .ok_or_else(|| ScanError::Parse("empty include".into()))?;
422      collect_namespaces(rng, inner_root);
423      // Push the included file's directory to the search path so its
424      // own includes resolve correctly.
425      let dir = path.parent().unwrap_or_else(|| Path::new("."));
426      let mut nested_paths: Vec<&Path> = Vec::with_capacity(ctx.search_paths.len() + 1);
427      nested_paths.push(dir);
428      nested_paths.extend(&ctx.search_paths);
429      let mut nested_ctx = ScanContext { search_paths: nested_paths };
430
431      // Ignore the outer <grammar>, if any (`<include>` doesn't establish
432      // a binding in RelaxNG).
433      let patterns = if get_relax_op(inner_root).as_deref() == Some("rng:grammar") {
434        let nns = inner_root
435          .get_attribute("ns")
436          .or_else(|| inherit_ns.map(String::from));
437        scan_grammar_content(
438          rng,
439          nns.as_deref(),
440          get_elements(inner_root),
441          &mut nested_ctx,
442        )?
443      } else {
444        scan_pattern(rng, inner_root, None, &mut nested_ctx)?
445      };
446
447      let modname = strip_rng_ext(&href);
448      let module = Pattern::Module { name: modname, body: patterns };
449      let replacements = scan_grammar_content(rng, ns_ref, children, ctx)?;
450      if replacements.is_empty() {
451        Ok(vec![module])
452      } else {
453        Ok(vec![Pattern::Override {
454          module: Box::new(module),
455          replacements,
456        }])
457      }
458    },
459    _ => Ok(Vec::new()),
460  }
461}
462
463/// Walk a `<name>`/`<anyName>`/`<nsName>`/`<choice>`/`<except>` name-class.
464/// Returns the qnames covered, exclusions appearing as `!qname` per the
465/// Perl convention.
466fn scan_name_class(
467  rng: &mut Relaxng,
468  node: RoNode,
469  for_attr: bool,
470  ns: Option<&str>,
471) -> Vec<String> {
472  let Some(op) = get_relax_op(node) else {
473    return Vec::new();
474  };
475  match op.as_str() {
476    "rng:name" => {
477      let raw = node.get_content();
478      let (decns, local) = decode_qname(rng, &raw);
479      let effective_ns = decns.as_deref().or(ns);
480      let resolved_ns = if for_attr { None } else { effective_ns };
481      vec![encode_qname(rng, resolved_ns, &local)]
482    },
483    "rng:anyName" => {
484      let except: Vec<String> = get_elements(node)
485        .into_iter()
486        .flat_map(|c| scan_name_class(rng, c, for_attr, ns))
487        .collect();
488      let mut all = vec!["*".to_string(), "*:*".to_string()];
489      all.extend(except);
490      filter_names(all)
491    },
492    "rng:nsName" => {
493      let xns = node.get_attribute("ns").or_else(|| ns.map(String::from));
494      let star = encode_qname(rng, xns.as_deref(), "*");
495      let except: Vec<String> = get_elements(node)
496        .into_iter()
497        .flat_map(|c| scan_name_class(rng, c, for_attr, ns))
498        .collect();
499      let mut all = vec![star];
500      all.extend(except);
501      filter_names(all)
502    },
503    "rng:choice" => {
504      let mut names = std::collections::BTreeSet::new();
505      let mut child = node.get_first_child();
506      while let Some(c) = child {
507        for n in scan_name_class(rng, c, for_attr, ns) {
508          names.insert(n);
509        }
510        child = c.get_next_sibling();
511      }
512      names.into_iter().collect()
513    },
514    "rng:except" => {
515      let mut names = std::collections::BTreeSet::new();
516      for c in get_elements(node) {
517        for n in scan_name_class(rng, c, for_attr, ns) {
518          names.insert(n);
519        }
520      }
521      names.into_iter().map(|n| format!("!{}", n)).collect()
522    },
523    _ => Vec::new(),
524  }
525}
526
527/// Collapse `(*:*, !*:*)` etc. — drops exclusions that cancel an
528/// inclusion. Perl `filterNames`.
529fn filter_names(names: Vec<String>) -> Vec<String> {
530  use std::collections::BTreeMap;
531  let mut include: BTreeMap<String, String> = BTreeMap::new();
532  let mut exclude: BTreeMap<String, String> = BTreeMap::new();
533  for n in names {
534    if let Some(rest) = n.strip_prefix('!') {
535      exclude.insert(n.clone(), rest.to_string());
536    } else {
537      include.insert(n.clone(), n);
538    }
539  }
540  let drop_keys: Vec<String> = exclude
541    .iter()
542    .filter(|(_, target)| include.contains_key(target.as_str()))
543    .map(|(k, _)| k.clone())
544    .collect();
545  for k in drop_keys {
546    if let Some(target) = exclude.remove(&k) {
547      include.remove(&target);
548    }
549  }
550  include
551    .keys()
552    .cloned()
553    .chain(exclude.keys().cloned())
554    .collect()
555}
556
557/// Split a `prefix:local` token into `(ns_uri?, local)` using the
558/// schema's namespace map. Mirrors Perl's `decodeQName`.
559fn decode_qname(rng: &Relaxng, raw: &str) -> (Option<String>, String) {
560  match raw.split_once(':') {
561    Some((prefix, local)) => match rng.document_namespaces.get(prefix) {
562      Some(uri) => (Some(uri.clone()), local.to_string()),
563      None => (None, raw.to_string()),
564    },
565    None => (None, raw.to_string()),
566  }
567}
568
569// ----- helpers ------------------------------------------------------------
570
571fn collect_namespaces(rng: &mut Relaxng, root: RoNode) {
572  for ns in root.get_namespace_declarations() {
573    let prefix = ns.get_prefix();
574    let href = ns.get_href();
575    if href.starts_with("http://relaxng.org") {
576      continue;
577    }
578    // If the schema author bound a different prefix to this URI, drop
579    // any pre-seeded conventional binding so the schema's choice wins.
580    if !prefix.is_empty() {
581      rng
582        .document_namespaces
583        .retain(|p, u| p == &prefix || u != &href);
584    }
585    rng.document_namespaces.insert(prefix, href);
586  }
587}
588
589fn strip_rng_ext(name: &str) -> String {
590  name
591    .strip_suffix(".rng")
592    .or_else(|| name.strip_suffix(".rnc"))
593    .unwrap_or(name)
594    .to_string()
595}
596
597/// Resolve a schema reference. Honours LaTeXML's `urn:x-LaTeXML:RelaxNG:`
598/// URN scheme: strips the prefix, then translates remaining `:`
599/// separators into path separators so e.g.
600/// `urn:x-LaTeXML:RelaxNG:svg:svg11.rng` → `svg/svg11.rng` lookup.
601///
602/// Resolve a RelaxNG schema reference — a filename or a `urn:x-LaTeXML:RelaxNG:`
603/// URN — to a parsed document. Disk first (via [`find_file`]), then the embedded
604/// table ([`super::embedded::lookup`]). Mirrors Perl's `RelaxNG->new`
605/// (`Common/XML/RelaxNG.pm:28-51`), which BOTH `scanExternal` and the `rng:include`
606/// handler call — so an `<include>` and an `<externalRef>` resolve identically
607/// (#538; before, only `scan_external` consulted the embedded table). Perl
608/// appends `.rng` when absent (`RelaxNG.pm:33`); we do the same for the embedded
609/// key so `urn:x-LaTeXML:RelaxNG:LaTeXML` (no extension) resolves.
610///
611/// The URN is stripped to a bare relative path in Rust and the embedded lookup
612/// keys on that — a `urn:` is never handed to libxml2 for URI composition, so the
613/// resolution is identical on macOS and Linux (whose libxml2 differ there).
614fn resolve_schema_ref(
615  parser: &XmlParser,
616  name: &str,
617  search_paths: &[&Path],
618) -> Result<(PathBuf, XmlDocument), ScanError> {
619  if let Some(p) = find_file(name, search_paths) {
620    let doc = parser
621      .parse_file(p.to_str().unwrap_or(""))
622      .map_err(|e| ScanError::Parse(format!("{:?}", e)))?;
623    return Ok((p, doc));
624  }
625  // Not on disk — fall back to the embedded table. Strip the URN scheme the same
626  // way `find_file` does so the key matches what `build.rs` recorded under
627  // `resources/RelaxNG/`, and append `.rng` when absent (Perl `RelaxNG.pm:33`).
628  let mut embed_key = match name.strip_prefix("urn:x-LaTeXML:RelaxNG:") {
629    Some(rest) => rest.replace(':', "/"),
630    None => name.to_string(),
631  };
632  if !embed_key.ends_with(".rng") {
633    embed_key.push_str(".rng");
634  }
635  let bytes =
636    super::embedded::lookup(&embed_key).ok_or_else(|| ScanError::FileNotFound(name.to_string()))?;
637  let doc = parser
638    .parse_string(bytes)
639    .map_err(|e| ScanError::Parse(format!("{:?}", e)))?;
640  Ok((PathBuf::from(&embed_key), doc))
641}
642
643/// Disk-only — embedded schemas are handled by [`resolve_schema_ref`], which
644/// consults the [`super::embedded::lookup`] table when `find_file` returns
645/// `None`. Keeping this function disk-only means developer-tree edits and
646/// system-installed schemas continue to win over the bundled copies.
647fn find_file(name: &str, search_paths: &[&Path]) -> Option<PathBuf> {
648  let bare = match name.strip_prefix("urn:x-LaTeXML:RelaxNG:") {
649    Some(rest) => rest.replace(':', "/"),
650    None => name.to_string(),
651  };
652  let asis = Path::new(&bare);
653  if asis.is_file() {
654    return Some(asis.to_path_buf());
655  }
656  for dir in search_paths {
657    let candidate = dir.join(&bare);
658    if candidate.is_file() {
659      return Some(candidate);
660    }
661  }
662  None
663}
664
665// ----- unit tests ---------------------------------------------------------
666
667#[cfg(test)]
668mod tests {
669  use super::*;
670  use crate::common::relaxng::Pattern;
671
672  fn matches_combination(pat: &Pattern, op: CombineOp) -> bool {
673    matches!(pat, Pattern::Combination { op: o, .. } if *o == op)
674  }
675
676  #[test]
677  fn scan_empty_grammar() {
678    let xml = r#"<grammar xmlns="http://relaxng.org/ns/structure/1.0"></grammar>"#;
679    let mut rng = Relaxng::default();
680    let patterns = scan_string(&mut rng, xml).expect("scan");
681    assert_eq!(patterns.len(), 1);
682    match &patterns[0] {
683      Pattern::Grammar { name, body } => {
684        assert_eq!(name, "grammar1");
685        assert!(body.is_empty());
686      },
687      other => panic!("expected Grammar, got {:?}", other),
688    }
689  }
690
691  #[test]
692  fn scan_simple_element() {
693    let xml = r#"
694      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
695        <start><element name="root"><empty/></element></start>
696      </grammar>
697    "#;
698    let mut rng = Relaxng::default();
699    let patterns = scan_string(&mut rng, xml).expect("scan");
700    let body = match &patterns[0] {
701      Pattern::Grammar { body, .. } => body,
702      other => panic!("expected Grammar, got {:?}", other),
703    };
704    let start_body = match &body[0] {
705      Pattern::Start { body } => body,
706      other => panic!("expected Start, got {:?}", other),
707    };
708    match &start_body[0] {
709      Pattern::Element { name, body: _ } => assert_eq!(name, "root"),
710      other => panic!("expected Element, got {:?}", other),
711    }
712  }
713
714  #[test]
715  fn scan_choice_combinator() {
716    let xml = r#"
717      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
718        <start>
719          <choice>
720            <element name="a"><empty/></element>
721            <element name="b"><empty/></element>
722          </choice>
723        </start>
724      </grammar>
725    "#;
726    let mut rng = Relaxng::default();
727    let patterns = scan_string(&mut rng, xml).expect("scan");
728    let body = match &patterns[0] {
729      Pattern::Grammar { body, .. } => body,
730      _ => unreachable!(),
731    };
732    let start_body = match &body[0] {
733      Pattern::Start { body } => body,
734      _ => unreachable!(),
735    };
736    assert!(matches_combination(&start_body[0], CombineOp::Choice));
737  }
738
739  /// #538: a `<include href="urn:x-LaTeXML:RelaxNG:…">` must resolve from the
740  /// embedded schema table when the file is not on disk (the installed-binary
741  /// path — `resources/RelaxNG/` is absent, and `scan_string` uses empty search
742  /// paths). Before the fix the `rng:include` arm used disk-only `find_file` and
743  /// failed `FileNotFound`, while `externalRef`/`scan_external` had the embedded
744  /// fallback — an asymmetry Perl doesn't have (both go through `RelaxNG->new`).
745  #[test]
746  fn rng_include_resolves_urn_via_embedded() {
747    // Extension form (as written in the bundled LaTeXML.rng).
748    let xml = r#"
749      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
750        <include href="urn:x-LaTeXML:RelaxNG:LaTeXML-common.rng"/>
751      </grammar>
752    "#;
753    let mut rng = Relaxng::default();
754    scan_string(&mut rng, xml)
755      .expect("urn include (with .rng) should resolve from the embedded table");
756
757    // No-extension form (the issue title: `urn:x-LaTeXML:RelaxNG:LaTeXML`).
758    // Perl appends `.rng` (Common/XML/RelaxNG.pm:33), so this must resolve too.
759    let xml_noext = r#"
760      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
761        <include href="urn:x-LaTeXML:RelaxNG:LaTeXML-common"/>
762      </grammar>
763    "#;
764    let mut rng2 = Relaxng::default();
765    scan_string(&mut rng2, xml_noext)
766      .expect("no-extension urn include should resolve (.rng appended)");
767  }
768
769  #[test]
770  fn scan_define_with_combine_choice() {
771    let xml = r#"
772      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
773        <define name="X"><element name="x1"><empty/></element></define>
774        <define name="X" combine="choice"><element name="x2"><empty/></element></define>
775      </grammar>
776    "#;
777    let mut rng = Relaxng::default();
778    let patterns = scan_string(&mut rng, xml).expect("scan");
779    let body = match &patterns[0] {
780      Pattern::Grammar { body, .. } => body,
781      _ => unreachable!(),
782    };
783    assert_eq!(body.len(), 2);
784    match &body[0] {
785      Pattern::Def {
786        combiner: DefCombiner::Group,
787        name,
788        ..
789      } => assert_eq!(name, "X"),
790      other => panic!("expected Def(Group), got {:?}", other),
791    }
792    match &body[1] {
793      Pattern::Def {
794        combiner: DefCombiner::Choice,
795        name,
796        ..
797      } => assert_eq!(name, "X"),
798      other => panic!("expected Def(Choice), got {:?}", other),
799    }
800  }
801
802  #[test]
803  fn scan_attribute_with_value() {
804    let xml = r#"
805      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
806        <start>
807          <element name="root">
808            <attribute name="kind"><value>sample</value></attribute>
809          </element>
810        </start>
811      </grammar>
812    "#;
813    let mut rng = Relaxng::default();
814    let patterns = scan_string(&mut rng, xml).expect("scan");
815    // descend Grammar → Start → Element → Attribute → Value
816    let attr_body = match &patterns[0] {
817      Pattern::Grammar { body, .. } => match &body[0] {
818        Pattern::Start { body: sb } => match &sb[0] {
819          Pattern::Element { body: eb, .. } => match &eb[0] {
820            Pattern::Attribute { body: ab, .. } => ab.clone(),
821            _ => unreachable!(),
822          },
823          _ => unreachable!(),
824        },
825        _ => unreachable!(),
826      },
827      _ => unreachable!(),
828    };
829    match &attr_body[0] {
830      Pattern::Value(v) => assert_eq!(v, "sample"),
831      other => panic!("expected Value, got {:?}", other),
832    }
833  }
834
835  #[test]
836  fn scan_documentation_annotation() {
837    let xml = r#"
838      <grammar
839        xmlns="http://relaxng.org/ns/structure/1.0"
840        xmlns:a="http://relaxng.org/ns/compatibility/annotations/1.0">
841        <define name="X">
842          <a:documentation>An example pattern</a:documentation>
843          <element name="x"><empty/></element>
844        </define>
845      </grammar>
846    "#;
847    let mut rng = Relaxng::default();
848    let patterns = scan_string(&mut rng, xml).expect("scan");
849    let body = match &patterns[0] {
850      Pattern::Grammar { body, .. } => body,
851      _ => unreachable!(),
852    };
853    let def_body = match &body[0] {
854      Pattern::Def { body, .. } => body,
855      _ => unreachable!(),
856    };
857    match &def_body[0] {
858      Pattern::Doc(s) => assert_eq!(s, "An example pattern"),
859      other => panic!("expected Doc, got {:?}", other),
860    }
861  }
862
863  #[test]
864  fn scan_mixed_normalises_to_interleave_with_text() {
865    let xml = r#"
866      <grammar xmlns="http://relaxng.org/ns/structure/1.0">
867        <start>
868          <mixed><element name="b"><empty/></element></mixed>
869        </start>
870      </grammar>
871    "#;
872    let mut rng = Relaxng::default();
873    let patterns = scan_string(&mut rng, xml).expect("scan");
874    let inner = match &patterns[0] {
875      Pattern::Grammar { body, .. } => match &body[0] {
876        Pattern::Start { body } => match &body[0] {
877          Pattern::Combination { op, body } => (op, body.clone()),
878          _ => unreachable!(),
879        },
880        _ => unreachable!(),
881      },
882      _ => unreachable!(),
883    };
884    assert_eq!(*inner.0, CombineOp::Interleave);
885    assert!(matches!(inner.1[0], Pattern::Text));
886  }
887
888  #[test]
889  fn filter_names_drops_canceling_exclusion() {
890    let names = vec!["x".into(), "y".into(), "!y".into()];
891    let result = filter_names(names);
892    assert_eq!(result, vec!["x".to_string()]);
893  }
894
895  #[test]
896  fn filter_names_preserves_uncanceled_exclusion() {
897    let names = vec!["x".into(), "!z".into()];
898    let result = filter_names(names);
899    assert_eq!(result, vec!["x".to_string(), "!z".to_string()]);
900  }
901}