Skip to main content

latexml_core/rewrite/
declare.rs

1//! The `\lxDeclare` pattern compiler and its paired structural matcher.
2//!
3//! Perl keeps this machinery in `Core/Rewrite.pm` (`domToXPath` digests the
4//! declaration pattern and compiles an XPath with baked-in predicates); the
5//! Rust port instead recognizes the pattern SOURCE string (one arm per
6//! structural family), emits a deliberately BROAD XPath, and verifies each
7//! match Rust-side in [`declare_node_matches`] — sidestepping the nested
8//! XPath-predicate problems and the font-at-rewrite-time trap (see
9//! `base_text_predicate`). The compiler ([`compile_declare_pattern`]) and the
10//! matcher are a PAIRED construction: every [`DeclarePatternType`] variant has
11//! one arm in each, and both matches are exhaustive so adding a family breaks
12//! both at compile time (the same drift-protection principle as the
13//! fingerprint/estimate pair in `digested.rs`).
14
15use libxml::tree::Node;
16
17use crate::document::Document;
18
19/// Structural family of a compiled `\lxDeclare` pattern. One variant per
20/// compiler arm; consumed exhaustively by [`declare_node_matches`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DeclarePatternType {
23  /// Bare token / symbol command (`x`, `\pi`) — the XPath is exact.
24  Simple,
25  /// Wildcard subscript `x_\WildCard` / `x_{\WildCard,...}` (arity in sub_text).
26  Subscript,
27  /// Literal subscript `x_1`, `x_{2n-1}`.
28  LiteralSubscript,
29  /// Prime `x'` / `x^{\prime}`.
30  Prime,
31  /// Accent `\hat{\WildCard}` / `\hat{x}`.
32  Accent,
33  /// Function application `f\WildCard[(\WildCard,...)]` (arity in sub_text).
34  FuncApply,
35  /// Leading wildcard with literal content `\WildCard[a]b`.
36  LeadWild,
37  /// Command application `\cs{\WildCard}...` matching the use-site XMDual.
38  CmdDual,
39  /// Unrecognized pattern — compiles to an empty XPath, never matches.
40  Unknown,
41}
42
43/// Metadata for a compiled \lxDeclare pattern.
44/// Contains the XPath, pattern type for Rust-side filtering, and wildcard info.
45#[derive(Debug, Clone)]
46pub struct DeclarePattern {
47  pub xpath:          String,
48  pub pattern_type:   DeclarePatternType,
49  /// Base token text for subscript/prime/accent base matching (e.g. "x")
50  pub base_text:      Option<String>,
51  /// For literal subscripts: the subscript content text (e.g. "1")
52  pub sub_text:       Option<String>,
53  /// For accent patterns: the accent name (e.g. "hat")
54  pub accent_name:    Option<String>,
55  #[allow(dead_code)]
56  pub has_wildcard:   bool,
57  pub wildcard_paths: Option<Vec<Vec<usize>>>,
58  /// Font CLASS the matched base must carry (e.g. "caligraphic"), checked
59  /// Rust-side — never baked into the XPath (see base_text_predicate).
60  pub font_class:     Option<&'static str>,
61}
62
63impl DeclarePattern {
64  /// Number of sibling nodes the match spans — Perl's `$nnodes` from
65  /// `domToXPath` (Rewrite.pm). Subscript/prime patterns match the base
66  /// XMTok plus its POSTSUBSCRIPT/POSTSUPERSCRIPT sibling; accents match
67  /// the single XMApp; function applications span base + `(` + n args +
68  /// (n-1) commas + `)`.
69  pub fn select_count(&self) -> Option<usize> {
70    match self.pattern_type {
71      DeclarePatternType::LiteralSubscript
72      | DeclarePatternType::Prime
73      | DeclarePatternType::Subscript => Some(2),
74      DeclarePatternType::Accent => Some(1),
75      DeclarePatternType::FuncApply => self
76        .sub_text
77        .as_deref()
78        .and_then(|s| s.parse::<usize>().ok())
79        .map(|n| 2 * n + 2),
80      // wildcard content tokens + literal suffix tokens
81      DeclarePatternType::LeadWild => match (&self.base_text, &self.sub_text) {
82        (Some(content), Some(suffix)) => Some(content.chars().count() + suffix.chars().count()),
83        _ => None,
84      },
85      _ => None,
86    }
87  }
88}
89
90/// Generate an XPath text predicate for a base token specification, plus a
91/// font-CLASS requirement checked RUST-SIDE (declare_node_matches).
92///
93/// NEVER bake `@font` into the XPath: the serialized attribute does not
94/// exist at rewrite time (only the interned `_font` id does), so a
95/// `@font='caligraphic'` predicate silently matches NOTHING — the historical
96/// wildcard-vanish failure mode (declare.tex golden was 51 decl_id vs Perl's
97/// 84 until 2026-07-03). Likewise digestion stamps command tokens with
98/// `@name` (e.g. varepsilon), not only `@meaning` — accept either.
99fn base_text_predicate(base: &str) -> (String, Option<&'static str>) {
100  if base.starts_with('\\') {
101    let cmd = base.trim_start_matches('\\');
102    if let Some(inner) = cmd
103      .strip_prefix("mathcal{")
104      .and_then(|s| s.strip_suffix('}'))
105    {
106      (format!("text()='{inner}'"), Some("caligraphic"))
107    } else {
108      (format!("(@meaning='{cmd}' or @name='{cmd}')"), None)
109    }
110  } else {
111    (format!("text()='{}'", base.replace('\'', "&apos;")), None)
112  }
113}
114
115/// Compile a \lxDeclare body_text into pattern metadata.
116/// Handles both wildcard and non-wildcard patterns.
117///
118/// Perl: compile_match1 digests tokens to DOM, then domToXPath.
119/// Rust: pattern-match on body_text string and generate broad XPath
120/// with Rust-side filtering criteria (avoids XPath nested predicate bug).
121///
122/// Font-awareness is deliberately NOT baked into these XPaths: the serialized
123/// `@font` attribute is only finalized after math parsing, so a rewrite-time
124/// `@font='…'` predicate matches nothing (and would silently break the
125/// wildcard/subscript/prime rewrites). Font discrimination happens Rust-side
126/// instead — `declare_node_matches` (rewrite path, via the resolved `_font`
127/// id) and `apply_lx_declarations` (post-rewrite fast path, via match_font).
128pub fn compile_declare_pattern(body_text: &str) -> DeclarePattern {
129  // === Subscript patterns ===
130  // IMPORTANT: Rewrites run BEFORE math parsing. The pre-parsed DOM has:
131  //   <XMTok>x</XMTok> <XMApp role="POSTSUBSCRIPT"><XMTok>n</XMTok></XMApp>
132  // NOT the post-parsed: <XMApp><XMTok role="SUBSCRIPTOP"/><XMTok>x</XMTok><XMTok>n</XMTok></XMApp>
133  // Match the BASE XMTok, with select_count=2 to include the POSTSUBSCRIPT sibling.
134  // Rust-side filtering verifies the sibling structure.
135
136  // Wildcard: x_\WildCard, \varepsilon_\WildCard, \mathcal{T}_\WildCard
137  if let Some(base) = body_text.strip_suffix("_\\WildCard") {
138    let base = base.trim().to_string();
139    let (base_pred, font_class) = base_text_predicate(&base);
140    return DeclarePattern {
141      // Match the base XMTok; Rust-side filter checks POSTSUBSCRIPT sibling
142      xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
143      pattern_type: DeclarePatternType::Subscript,
144      base_text: Some(base),
145      sub_text: None,
146      accent_name: None,
147      has_wildcard: true,
148      // Wildcard = child 1 of sibling 2 (the content of POSTSUBSCRIPT XMApp)
149      wildcard_paths: Some(vec![vec![2, 1]]),
150      font_class,
151    };
152  }
153  // Braced wildcard subscripts: x_{\WildCard}, x_{\WildCard,\WildCard}
154  if body_text.contains("_{\\WildCard")
155    && let Some(idx) = body_text.find("_{")
156  {
157    let base = body_text[..idx].trim().to_string();
158    let (base_pred, font_class) = base_text_predicate(&base);
159    let brace_content = &body_text[idx + 2..body_text.len().saturating_sub(1)];
160    let nwilds = brace_content.matches("\\WildCard").count();
161    // Perl semantics diverge by arity (Rewrite.pm domToXPath):
162    //  - ONE wildcard: the XMArg-single-wildcard branch matches the WHOLE
163    //    subscript argument regardless of content (that is the fixture's
164    //    "accidental" q_{a+b} match) — wildcard = child 1 of sibling 2.
165    //  - TWO+: the wildcards and literal commas compile as a positional
166    //    child sequence [*, ',', *, ...] — wildcard i = content child 2i-1
167    //    (commas at the even positions), and declare_node_matches must
168    //    verify the comma-list shape (`sub_text` carries the arity).
169    let (wpaths, sub_text) = if nwilds <= 1 {
170      (vec![vec![2, 1]], None)
171    } else {
172      (
173        (1..=nwilds).map(|i| vec![2, 1, 2 * i - 1]).collect(),
174        Some(nwilds.to_string()),
175      )
176    };
177    return DeclarePattern {
178      xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
179      pattern_type: DeclarePatternType::Subscript,
180      base_text: Some(base),
181      sub_text,
182      accent_name: None,
183      has_wildcard: true,
184      wildcard_paths: Some(wpaths),
185      font_class,
186    };
187  }
188  // Literal subscript: x_1, x_{1}, x_{2n-1}
189  // Pre-parsed: XMTok[x] + XMApp[POSTSUBSCRIPT, XMTok[1]]
190  if let Some((base, sub)) = parse_subscript_literal(body_text) {
191    let base_pred = format!("text()='{}'", base.replace('\'', "&apos;"));
192    return DeclarePattern {
193      xpath:          format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
194      pattern_type:   DeclarePatternType::LiteralSubscript,
195      base_text:      Some(base),
196      sub_text:       Some(sub),
197      accent_name:    None,
198      has_wildcard:   false,
199      wildcard_paths: None,
200      font_class:     None,
201    };
202  }
203
204  // === Accent patterns ===
205  // Wildcard accent: \hat{\WildCard}, \widehat{\WildCard}
206  for accent in &[
207    "hat", "widehat", "tilde", "bar", "vec", "dot", "ddot", "check", "breve",
208  ] {
209    let pattern = format!("\\{accent}{{\\WildCard}}");
210    if body_text == pattern {
211      return DeclarePattern {
212        // Broad: match any XMApp. Rust filters by accent name in first child.
213        xpath:          "descendant-or-self::*[local-name()='XMApp']".to_string(),
214        pattern_type:   DeclarePatternType::Accent,
215        base_text:      None,
216        sub_text:       None,
217        accent_name:    Some(accent.to_string()),
218        has_wildcard:   true,
219        // Wildcard = child 2 (base content) of the accent XMApp
220        wildcard_paths: Some(vec![vec![1, 2]]),
221        font_class:     None,
222      };
223    }
224  }
225  // Literal accent: \hat{x}, \widehat{x}
226  for accent in &[
227    "hat", "widehat", "tilde", "bar", "vec", "dot", "ddot", "check", "breve",
228  ] {
229    if let Some(rest) = body_text.strip_prefix(&format!("\\{accent}{{"))
230      && let Some(inner) = rest.strip_suffix('}')
231      && !inner.contains("WildCard")
232    {
233      return DeclarePattern {
234        xpath:          "descendant-or-self::*[local-name()='XMApp']".to_string(),
235        pattern_type:   DeclarePatternType::Accent,
236        base_text:      Some(inner.to_string()),
237        sub_text:       None,
238        accent_name:    Some(accent.to_string()),
239        has_wildcard:   false,
240        wildcard_paths: None,
241        font_class:     None,
242      };
243    }
244  }
245
246  // === Prime pattern ===
247  // x^{\prime} → after parsing: XMApp[SUPERSCRIPTOP, XMTok(x), XMTok(prime)]
248  // Match the XMApp with SUPERSCRIPTOP and base text.
249  if let Some(base) = body_text.strip_suffix("^{\\prime}") {
250    let base = base.trim().to_string();
251    if !base.is_empty() && !base.contains('\\') {
252      let base_pred = format!("text()='{}'", base.replace('\'', "&apos;"));
253      return DeclarePattern {
254        // Pre-parsed: XMTok[x] + XMApp[POSTSUPERSCRIPT, XMTok[prime]]
255        xpath:          format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
256        pattern_type:   DeclarePatternType::Prime,
257        base_text:      Some(base),
258        sub_text:       None,
259        accent_name:    None,
260        has_wildcard:   false,
261        wildcard_paths: None,
262        font_class:     None,
263      };
264    }
265  }
266  // Also handle raw prime: x'
267  if body_text.ends_with('\'') && body_text.len() > 1 {
268    let base = body_text[..body_text.len() - 1].trim().to_string();
269    if !base.is_empty() && !base.contains('\\') {
270      let base_pred = format!("text()='{}'", base.replace('\'', "&apos;"));
271      return DeclarePattern {
272        // Pre-parsed: XMTok[x] + XMApp[POSTSUPERSCRIPT, XMTok[prime]]
273        xpath:          format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
274        pattern_type:   DeclarePatternType::Prime,
275        base_text:      Some(base),
276        sub_text:       None,
277        accent_name:    None,
278        has_wildcard:   false,
279        wildcard_paths: None,
280        font_class:     None,
281      };
282    }
283  }
284
285  // === Function application: base\WildCard[(\WildCard)] / [(\WildCard,\WildCard)] ===
286  // Perl digests `\WildCard[content]` into <_WildCard_>content</_WildCard_>;
287  // domToXPath (Rewrite.pm L443-450) compiles the CONTENT as literal following
288  // siblings of the base — `(`, one single-node arg per \WildCard (comma-
289  // separated), `)` at exact positions — and counts EVERY content node as a
290  // wildcard position. So `f\WildCard[(\WildCard)]` matches the pre-parse
291  // token run `f ( a )` (single-token args only: `f(a+b)` does NOT match,
292  // its `)` sits past the position predicate), marking the base as the
293  // non-wildcard attribute carrier (nowrap) or wrapping the span in an
294  // XMDual whose content applies the decl-op to XMRefs of `(`/arg/`)`.
295  if let Some(idx) = body_text.find("\\WildCard[(") {
296    let base = body_text[..idx].trim().to_string();
297    let content = &body_text[idx + "\\WildCard[(".len()..];
298    if let Some(args) = content.strip_suffix(")]")
299      && !base.is_empty()
300    {
301      let parts: Vec<&str> = args.split(',').collect();
302      if parts.iter().all(|p| p.trim() == "\\WildCard") {
303        let nargs = parts.len();
304        let (base_pred, font_class) = base_text_predicate(&base);
305        // Sibling positions 2..=2n+2 (the whole parenthesized content) are
306        // wildcards, matching Perl's `$n = scalar(@children)` counting.
307        let span = 2 * nargs + 2;
308        let wpaths = (2..=span).map(|i| vec![i]).collect();
309        return DeclarePattern {
310          xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
311          pattern_type: DeclarePatternType::FuncApply,
312          base_text: Some(base),
313          sub_text: Some(nargs.to_string()),
314          accent_name: None,
315          has_wildcard: true,
316          wildcard_paths: Some(wpaths),
317          font_class,
318        };
319      }
320    }
321  }
322
323  // === Leading wildcard with literal content: \WildCard[a]b, \WildCard[ab]c ===
324  // Perl digests `\WildCard[content]suffix` to [_WildCard_[tokens...], tokens...];
325  // domToXPath compiles the wildcard CONTENT as the leading match span (every
326  // content token a wildcard position — including the matched node itself,
327  // sibling 1) followed by the literal suffix tokens at exact positions. With
328  // nowrap, the attributes land on the first NON-wildcard node = the suffix.
329  if let Some(rest) = body_text.strip_prefix("\\WildCard[")
330    && let Some(close) = rest.find(']')
331  {
332    let content = &rest[..close];
333    let suffix = &rest[close + 1..];
334    if !content.is_empty()
335      && !suffix.is_empty()
336      && !content.contains('\\')
337      && !suffix.contains('\\')
338    {
339      let k = content.chars().count();
340      let first = content.chars().next().unwrap();
341      let wpaths = (1..=k).map(|i| vec![i]).collect();
342      return DeclarePattern {
343        xpath:          format!(
344          "descendant-or-self::*[local-name()='XMTok' and text()='{}']",
345          first.to_string().replace('\'', "&apos;")
346        ),
347        pattern_type:   DeclarePatternType::LeadWild,
348        base_text:      Some(content.to_string()),
349        sub_text:       Some(suffix.to_string()),
350        accent_name:    None,
351        has_wildcard:   true,
352        wildcard_paths: Some(wpaths),
353        font_class:     None,
354      };
355    }
356  }
357
358  // === Command application with wildcard args: \weird{\WildCard}{\WildCard} ===
359  // A DefMath-defined command (e.g. via \lxDefMath) digests each USE into an
360  // XMDual whose content arm is XMApp(XMTok[@name=cs], XMRef per arg). Perl
361  // digests the pattern itself (with _WildCard_ args) and domToXPath matches
362  // that dual; setAttributes_wild's single-XMDual branch then sets the
363  // attributes (decl_id) directly on the dual node — mirrored here by the
364  // "cmddual" Rust-side filter with no wildcard paths (nmatched=1).
365  if body_text.starts_with('\\')
366    && let Some(cmd_end) = body_text.find("{\\WildCard}")
367  {
368    let cmd = &body_text[1..cmd_end];
369    let rest = &body_text[cmd_end..];
370    if !cmd.is_empty() && cmd.chars().all(|c| c.is_ascii_alphabetic()) {
371      let nargs = rest.matches("{\\WildCard}").count();
372      if nargs >= 1 && rest == "{\\WildCard}".repeat(nargs) {
373        return DeclarePattern {
374          xpath:          "descendant-or-self::*[local-name()='XMDual']".to_string(),
375          pattern_type:   DeclarePatternType::CmdDual,
376          base_text:      Some(cmd.to_string()),
377          sub_text:       Some(nargs.to_string()),
378          accent_name:    None,
379          has_wildcard:   true,
380          wildcard_paths: None,
381          font_class:     None,
382        };
383      }
384    }
385  }
386
387  // === Bare math symbol command, e.g. "\pi", "\alpha", "\cpi" ===
388  // Perl digests $\pi$ and matches the resulting XMTok via domToXPath. In our
389  // pre-parse DOM the symbol carries a `name` attribute equal to the control
390  // sequence (DefMath sets `name => <cs>`), so keying the match on @name is the
391  // string-pattern equivalent and lets \lxDeclare target symbol commands.
392  if let Some(cmd) = body_text.strip_prefix('\\')
393    && !cmd.is_empty()
394    && cmd.chars().all(|c| c.is_ascii_alphabetic())
395  {
396    return DeclarePattern {
397      xpath:          format!(
398        "descendant-or-self::*[local-name()='XMTok' and @name='{}']",
399        cmd
400      ),
401      pattern_type:   DeclarePatternType::Simple,
402      base_text:      None,
403      sub_text:       None,
404      accent_name:    None,
405      has_wildcard:   false,
406      wildcard_paths: None,
407      font_class:     None,
408    };
409  }
410
411  // === Fallback: simple token pattern ===
412  // For single characters/words without special structure, match as XMTok by text.
413  // This handles DefMathRewrite match strings like 'a', 'f', 'x', etc.
414  if !body_text.is_empty() && !body_text.contains('\\') {
415    return DeclarePattern {
416      xpath:          format!(
417        "descendant-or-self::*[local-name()='XMTok' and text()='{}']",
418        body_text.replace('\'', "&apos;")
419      ),
420      pattern_type:   DeclarePatternType::Simple,
421      base_text:      None,
422      sub_text:       None,
423      accent_name:    None,
424      has_wildcard:   false,
425      wildcard_paths: None,
426      font_class:     None,
427    };
428  }
429
430  // Truly unrecognized pattern (e.g. complex TeX commands without matching rules)
431  DeclarePattern {
432    xpath:          String::new(),
433    pattern_type:   DeclarePatternType::Unknown,
434    base_text:      None,
435    sub_text:       None,
436    accent_name:    None,
437    has_wildcard:   false,
438    wildcard_paths: None,
439    font_class:     None,
440  }
441}
442
443/// Parse a literal (non-wildcard) subscript pattern like "x_1" or "x_{2n-1}".
444/// Returns (base, subscript_content) if recognized.
445fn parse_subscript_literal(body_text: &str) -> Option<(String, String)> {
446  if body_text.contains("WildCard") {
447    return None;
448  }
449  // Check for _ subscript
450  let idx = body_text.find('_')?;
451  let base = body_text[..idx].trim().to_string();
452  if base.is_empty() {
453    return None;
454  }
455  let sub = body_text[idx + 1..].trim();
456  // Strip braces: {1} → 1, {2n-1} → 2n-1
457  let sub = sub
458    .strip_prefix('{')
459    .and_then(|s| s.strip_suffix('}'))
460    .unwrap_or(sub);
461  Some((base, sub.to_string()))
462}
463
464/// Rust-side filtering for \lxDeclare pattern matching.
465/// XPath matches are broad (to avoid nested predicate bugs); this function
466/// verifies the matched node's children match the specific pattern.
467///
468/// Pattern types:
469/// - "subscript": node is `XMApp[@role='POSTSUBSCRIPT']`, check base text + optional sub text
470/// - "prime": node is `XMApp[@role='POSTSUPERSCRIPT']`, check base text
471/// - "accent": node is XMApp, check accent name in first child, optional base text
472/// - "simple": no extra filtering needed (XPath is specific enough)
473pub fn declare_node_matches(document: &Document, node: &Node, pat: &DeclarePattern) -> bool {
474  let base_text = pat.base_text.as_deref();
475  let sub_text = pat.sub_text.as_deref();
476  let accent_name = pat.accent_name.as_deref();
477  let font_class = pat.font_class;
478  // Font-CLASS requirement (e.g. caligraphic for a \mathcal pattern): the
479  // XPath deliberately carries no @font predicate (the attribute is only an
480  // interned `_font` id at rewrite time) — discriminate here on the RESOLVED
481  // font instead. Class containment, not exact string (WISDOM: the exact
482  // serialized font string is unreliable at rewrite time).
483  if let Some(class) = font_class {
484    let font = document.get_node_font(node);
485    if !font.font_attribute_string().contains(class) {
486      return false;
487    }
488  }
489  let children = node.get_child_nodes();
490  match pat.pattern_type {
491    DeclarePatternType::LiteralSubscript => {
492      // Matched node is the BASE XMTok. Check that next sibling is POSTSUBSCRIPT
493      // with specific subscript content.
494      let next_sib = node.get_next_sibling();
495      let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
496      if next_role.as_deref() != Some("POSTSUBSCRIPT") {
497        return false;
498      }
499      // Check subscript content text
500      if let Some(sub) = sub_text {
501        let sub_content = next_sib
502          .as_ref()
503          .map(|s| s.get_content())
504          .unwrap_or_default();
505        if sub_content.trim() != sub {
506          return false;
507        }
508      }
509      true
510    },
511    DeclarePatternType::Subscript => {
512      // Wildcard subscript: matched node is BASE XMTok.
513      // Check that next sibling is POSTSUBSCRIPT.
514      let next_sib = node.get_next_sibling();
515      let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
516      if next_role.as_deref() != Some("POSTSUBSCRIPT") {
517        return false;
518      }
519      // Multi-wildcard `x_{\WildCard,\WildCard}`: sub_text carries the arity
520      // and the subscript content must be EXACTLY the comma list
521      // [any, ',', any, ...] — Perl compiles the literal commas as positional
522      // child predicates, so `q_{a}` / `q_{a+b}` do NOT match a 2-ary pattern
523      // (they fall to the 1-ary declaration, whose wildcard takes the whole
524      // argument). Content children live under the POSTSUBSCRIPT's first
525      // element child (the XMArg/XMWrap argument holder).
526      if let Some(n) = sub_text.and_then(|s| s.parse::<usize>().ok())
527        && n >= 2
528      {
529        let content: Vec<Node> = next_sib
530          .as_ref()
531          .and_then(|s| {
532            s.get_child_nodes()
533              .into_iter()
534              .find(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
535          })
536          .map(|holder| {
537            holder
538              .get_child_nodes()
539              .into_iter()
540              .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
541              .collect()
542          })
543          .unwrap_or_default();
544        if content.len() != 2 * n - 1 {
545          return false;
546        }
547        for (i, c) in content.iter().enumerate() {
548          // odd 0-based positions must be the literal comma separators
549          if i % 2 == 1 && (c.get_name() != "XMTok" || c.get_content().trim() != ",") {
550            return false;
551          }
552        }
553      }
554      true
555    },
556    DeclarePatternType::FuncApply => {
557      // Matched node is the base XMTok of `base\WildCard[(\WildCard...)]`.
558      // Require the EXACT following element siblings `(`, arg, [`,`, arg...],
559      // `)` — single-node args in the pre-parse DOM, mirroring Perl
560      // domToXPath_seq's position()=N predicates (so `f(a+b)` does not match
561      // an 1-ary pattern: its `)` sits past the expected position).
562      let Some(nargs) = sub_text.and_then(|s| s.parse::<usize>().ok()) else {
563        return false;
564      };
565      let mut expected: Vec<Option<&str>> = vec![Some("(")];
566      for i in 0..nargs {
567        if i > 0 {
568          expected.push(Some(","));
569        }
570        expected.push(None); // any single element node (the wildcard arg)
571      }
572      expected.push(Some(")"));
573      let mut cur = node.clone();
574      for want in expected {
575        let mut next = cur.get_next_sibling();
576        while let Some(ref s) = next {
577          if s.get_type() == Some(libxml::tree::NodeType::ElementNode) {
578            break;
579          }
580          next = s.get_next_sibling();
581        }
582        let Some(sib) = next else {
583          return false;
584        };
585        if let Some(text) = want
586          && (sib.get_name() != "XMTok" || sib.get_content().trim() != text)
587        {
588          return false;
589        }
590        cur = sib;
591      }
592      true
593    },
594    DeclarePatternType::CmdDual => {
595      // `\cs{\WildCard}...`: matched node is an XMDual whose content arm is
596      // XMApp(XMTok[@name=cs or @meaning=cs], one XMRef per wildcard arg).
597      let (Some(cmd), Some(nargs)) = (base_text, sub_text.and_then(|s| s.parse::<usize>().ok()))
598      else {
599        return false;
600      };
601      let elem_children: Vec<Node> = children
602        .iter()
603        .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
604        .cloned()
605        .collect();
606      let Some(content) = elem_children.first() else {
607        return false;
608      };
609      if content.get_name() != "XMApp" {
610        return false;
611      }
612      let app_children: Vec<Node> = content
613        .get_child_nodes()
614        .into_iter()
615        .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
616        .collect();
617      if app_children.len() != nargs + 1 {
618        return false;
619      }
620      let op = &app_children[0];
621      op.get_name() == "XMTok"
622        && (op.get_property("name").as_deref() == Some(cmd)
623          || op.get_property("meaning").as_deref() == Some(cmd))
624    },
625    DeclarePatternType::LeadWild => {
626      // `\WildCard[content]suffix`: matched node is the FIRST content token;
627      // the remaining content chars and then the literal suffix chars must
628      // follow as adjacent single-token element siblings (Perl domToXPath_seq
629      // position()=N predicates).
630      let (Some(content), Some(suffix)) = (base_text, sub_text) else {
631        return false;
632      };
633      let expected: Vec<char> = content.chars().skip(1).chain(suffix.chars()).collect();
634      let mut cur = node.clone();
635      for want in expected {
636        let mut next = cur.get_next_sibling();
637        while let Some(ref s) = next {
638          if s.get_type() == Some(libxml::tree::NodeType::ElementNode) {
639            break;
640          }
641          next = s.get_next_sibling();
642        }
643        let Some(sib) = next else {
644          return false;
645        };
646        if sib.get_name() != "XMTok" || sib.get_content().trim() != want.to_string() {
647          return false;
648        }
649        cur = sib;
650      }
651      true
652    },
653    DeclarePatternType::Prime => {
654      // Matched node is BASE XMTok. Check that next sibling is POSTSUPERSCRIPT
655      // with prime content.
656      let next_sib = node.get_next_sibling();
657      let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
658      if next_role.as_deref() != Some("POSTSUPERSCRIPT") {
659        return false;
660      }
661      // Check prime content
662      let sup_content = next_sib
663        .as_ref()
664        .map(|s| s.get_content())
665        .unwrap_or_default();
666      sup_content.contains('′')
667    },
668    DeclarePatternType::Accent => {
669      // XMApp with children: [accent_op, base_content]
670      if children.len() < 2 {
671        return false;
672      }
673      // Check accent name on first child
674      if let Some(accent) = accent_name {
675        let first_name = children[0]
676          .get_property("name")
677          .or_else(|| children[0].get_property("meaning"));
678        if first_name.as_deref() != Some(accent) {
679          return false;
680        }
681        // Accent ops should have OVERACCENT or UNDERACCENT role
682        let role = children[0].get_property("role");
683        let is_accent = role
684          .as_deref()
685          .map(|r| r.contains("ACCENT"))
686          .unwrap_or(false);
687        if !is_accent {
688          return false;
689        }
690      }
691      // Check base content text if specified
692      if let Some(base) = base_text
693        && !declare_base_matches(&children[1], base)
694      {
695        return false;
696      }
697      true
698    },
699    DeclarePatternType::Simple => {
700      // Font check: plain declarations (e.g. $x$) should NOT match tokens with
701      // non-default fonts (bold, caligraphic, typewriter).
702      // Perl: font_match_xpaths generates XPath predicates from _font attribute.
703      let font = document.get_node_font(node);
704      if let Some(series) = font.get_series()
705        && series.as_ref() == "bold"
706      {
707        return false;
708      }
709      if let Some(family) = font.get_family() {
710        let fam = family.as_ref();
711        if fam == "caligraphic" || fam == "typewriter" {
712          return false;
713        }
714      }
715      true
716    },
717    // Unknown compiles to an empty XPath and is never registered.
718    DeclarePatternType::Unknown => true,
719  }
720}
721
722/// Check if a node matches a base text specification.
723/// Handles both plain text (e.g. "x") and command names (e.g. "\varepsilon").
724fn declare_base_matches(node: &Node, base_spec: &str) -> bool {
725  if base_spec.starts_with('\\') {
726    // Command base: match by meaning or name attribute
727    let cmd = base_spec.trim_start_matches('\\');
728    // Handle \mathcal{X} → check font=caligraphic + text=X
729    if let Some(inner) = cmd
730      .strip_prefix("mathcal{")
731      .and_then(|s| s.strip_suffix('}'))
732    {
733      let font = node.get_property("font").unwrap_or_default();
734      let text = node.get_content();
735      return font == "caligraphic" && text.trim() == inner;
736    }
737    // General command: check meaning attribute
738    let meaning = node.get_property("meaning").unwrap_or_default();
739    meaning == cmd
740  } else {
741    // Plain text base: match node text content
742    let text = node.get_content();
743    text.trim() == base_spec
744  }
745}