Skip to main content

latexml_core/
rewrite.rs

1use std::{collections::VecDeque, fmt, rc::Rc};
2
3use libxml::tree::Node;
4use rustc_hash::FxHashMap as HashMap;
5
6use crate::{
7  common::{arena, error::*},
8  document::{Document, get_node_qname},
9  state::Scope,
10  tokens::Tokens,
11};
12
13pub mod declare;
14
15pub type RewriteReplaceClosure = Rc<dyn Fn(&mut Document, Vec<&mut Node>) -> Result<()>>;
16/// Test closure: Perl signature is ($document, $node) → $nnodes (0/undef = skip).
17pub type RewriteTestClosure = Rc<dyn Fn(&mut Document, &Node) -> Result<usize>>;
18/// Regexp closure: Perl signature is (\$string) → modified in-place via s///g.
19pub type RewriteRegexpClosure = Rc<dyn Fn(&str) -> Option<String>>;
20
21/// A single sub-pattern in a MultiSelect clause.
22/// Perl: `[$xpath, $nnodes, @wilds]`
23#[derive(Debug, Clone)]
24pub struct MultiSelectEntry {
25  pub xpath:  String,
26  pub nnodes: usize,
27  pub wilds:  Vec<WildPath>,
28}
29
30// ======================================================================
31// Defining Rewrite rules that act on the DOM
32// These are applied after the document is completely constructed
33#[derive(Clone, Default)]
34pub struct RewriteOptions {
35  pub label:          Option<String>,
36  pub scope:          Option<Scope>,
37  pub xpath:          Option<String>,
38  pub on_match:       Option<Tokens>,
39  pub attributes:     Option<String>,
40  pub attributes_map: Option<HashMap<String, String>>,
41  pub replace:        Option<RewriteReplaceClosure>,
42  pub regexp:         Option<String>,
43  pub select:         Option<String>,
44  pub select_count:   Option<usize>,
45  pub is_math:        bool,
46  pub wildcard_paths: Option<Vec<WildPath>>,
47  /// Declare-side structural filter for \lxDeclare rules — the compiled
48  /// [`declare::DeclarePattern`] whose broad XPath needs Rust-side
49  /// verification (`declare::declare_node_matches`) on every Select match.
50  /// Set by BOTH attribute and replace registrations.
51  pub declare_filter: Option<declare::DeclarePattern>,
52}
53impl fmt::Debug for RewriteOptions {
54  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "<RewriteOptions>") }
55}
56impl PartialEq for RewriteOptions {
57  fn eq(&self, other: &RewriteOptions) -> bool { self.select == other.select }
58}
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RewriteOperator {
61  // only uncompiled:
62  Label,
63  Scope,
64  Xpath,
65  Match,
66  // with available actions:
67  Regexp,
68  Attributes,
69  Action,
70  Replace,
71  Test,
72  MultiSelect,
73  Select,
74  Ignore,
75  Trace,
76}
77#[derive(Clone)]
78pub enum RewritePattern {
79  String(String),
80  Scope(Scope),
81  Tokens(Tokens),
82  Closure(RewriteReplaceClosure),
83  /// Test closure: returns number of matched nodes (0 = skip remaining clauses).
84  TestClosure(RewriteTestClosure),
85  /// Compiled regexp substitution: returns Some(modified) or None (no match).
86  RegexpClosure(RewriteRegexpClosure),
87  /// Multiple xpath+count+wilds tuples for MultiSelect.
88  MultiSelectPatterns(Vec<MultiSelectEntry>),
89  /// Pre-resolved node list (for scope resolution via DOM walking instead of XPath).
90  NodeList(Vec<Node>),
91}
92impl fmt::Debug for RewritePattern {
93  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
94    match self {
95      RewritePattern::String(x) => write!(f, "{x:?}"),
96      RewritePattern::Scope(x) => write!(f, "{x:?}"),
97      RewritePattern::Tokens(x) => write!(f, "{x:?}"),
98      RewritePattern::Closure(_) => write!(f, "<Rewrite Replacement Closure>"),
99      RewritePattern::TestClosure(_) => write!(f, "<Rewrite Test Closure>"),
100      RewritePattern::RegexpClosure(_) => write!(f, "<Rewrite Regexp Closure>"),
101      RewritePattern::MultiSelectPatterns(v) => write!(f, "<MultiSelect {} patterns>", v.len()),
102      RewritePattern::NodeList(v) => write!(f, "<NodeList {} nodes>", v.len()),
103    }
104  }
105}
106#[derive(Debug, Clone)]
107pub struct RewriteClause {
108  compiled:    bool,
109  pub op:      RewriteOperator,
110  pub pattern: RewritePattern,
111}
112impl RewriteClause {
113  pub fn new_uncompiled(op: RewriteOperator, pattern: RewritePattern) -> Self {
114    RewriteClause { compiled: false, op, pattern }
115  }
116
117  pub fn new_compiled(op: RewriteOperator, pattern: RewritePattern) -> Self {
118    RewriteClause { compiled: true, op, pattern }
119  }
120}
121
122#[derive(Debug, Clone, Default)]
123pub struct Rewrite {
124  pub options: RewriteOptions,
125  pub clauses: Vec<RewriteClause>,
126}
127impl PartialEq for Rewrite {
128  fn eq(&self, other: &Rewrite) -> bool { self.options == other.options }
129}
130
131impl Rewrite {
132  pub fn new(_kind: &str, mut options: RewriteOptions) -> Self {
133    use RewriteOperator::*;
134    let mut clauses = Vec::new();
135    // collect the non-compiling, early phase clauses from the options
136    if let Some(xpath) = options.select.take() {
137      clauses.push(RewriteClause {
138        compiled: true,
139        op:       Select,
140        pattern:  RewritePattern::String(xpath),
141      })
142    }
143    // collect the actionable clauses from the options
144    if let Some(label) = options.label.take() {
145      clauses.push(RewriteClause {
146        compiled: false,
147        op:       Label,
148        pattern:  RewritePattern::String(label),
149      });
150    }
151    if let Some(scope) = options.scope.take() {
152      // Convert Scope to string for compile_clause:
153      // Perl uses strings like "label:sec:restricted" or "id:S1"
154      let scope_str = match scope {
155        crate::state::Scope::Named(s) => arena::with(s, |r| r.to_string()),
156        crate::state::Scope::Global => String::from("global"),
157        crate::state::Scope::Local => String::from("local"),
158        // Rewrite rules never carry an in-place scope, but keep the match total
159        // (Perl's scope string for `assign_internal`'s 'inplace' branch).
160        crate::state::Scope::InPlace => String::from("inplace"),
161      };
162      clauses.push(RewriteClause {
163        compiled: false,
164        op:       Scope,
165        pattern:  RewritePattern::String(scope_str),
166      });
167    }
168    if let Some(xpath) = options.xpath.take() {
169      clauses.push(RewriteClause {
170        compiled: false,
171        op:       Xpath,
172        pattern:  RewritePattern::String(xpath),
173      });
174    }
175    if let Some(tokens) = options.on_match.take() {
176      clauses.push(RewriteClause {
177        compiled: false,
178        op:       Match,
179        pattern:  RewritePattern::Tokens(tokens),
180      });
181    }
182    if let Some(replace) = options.replace.take() {
183      clauses.push(RewriteClause {
184        compiled: false,
185        op:       Replace,
186        pattern:  RewritePattern::Closure(replace),
187      });
188    }
189    if let Some(r) = options.regexp.take() {
190      clauses.push(RewriteClause {
191        compiled: false,
192        op:       Regexp,
193        pattern:  RewritePattern::String(r),
194      });
195    }
196    // If attributes string is set but attributes_map is not, parse string into map.
197    // Perl format: "role='ID'" or "role='ID', meaning='foo'"
198    if options.attributes_map.is_none()
199      && let Some(ref attrs_str) = options.attributes
200    {
201      let mut map = HashMap::default();
202      for part in attrs_str.split(',') {
203        let part = part.trim();
204        if let Some((key, val)) = part.split_once('=') {
205          let val = val.trim().trim_matches('\'').trim_matches('"');
206          map.insert(key.trim().to_string(), val.to_string());
207        }
208      }
209      if !map.is_empty() {
210        options.attributes_map = Some(map);
211      }
212    }
213    if options.attributes_map.is_some() {
214      clauses.push(RewriteClause {
215        compiled: true,
216        op:       Attributes,
217        pattern:  RewritePattern::String(String::new()), // attributes stored in options
218      });
219    }
220    Rewrite { options, clauses }
221  }
222
223  pub fn compile_clauses(&mut self, document: &mut Document) {
224    let current_clauses: Vec<RewriteClause> = std::mem::take(&mut self.clauses);
225    let mut new_clauses: Vec<RewriteClause> = Vec::new();
226    for clause in current_clauses {
227      if !clause.compiled {
228        new_clauses.push(self.compile_clause(document, clause));
229      } else {
230        new_clauses.push(clause);
231      }
232    }
233    self.clauses = new_clauses;
234  }
235
236  pub fn compile_clause(
237    &mut self,
238    document: &mut Document,
239    clause: RewriteClause,
240  ) -> RewriteClause {
241    let op = clause.op;
242    let pattern = clause.pattern;
243
244    if op == RewriteOperator::Xpath {
245      if self.options.select_count.is_none() {
246        self.options.select_count = Some(1);
247      }
248      return RewriteClause {
249        compiled: true,
250        op: RewriteOperator::Select,
251        pattern,
252      };
253    }
254    // Perl Rewrite.pm L288-297: a `label` clause COMPILES to a select on the
255    // labeled node's xml:id (via getLabelID) — it has no runtime behavior.
256    // `scope => "label:<label>"` compiles through the identical getLabelID
257    // path (L300-302), so delegate to that branch below. (The former runtime
258    // Label arm instead tried to RECORD the current node's id under the
259    // label — not Perl semantics, and dead anyway: its bare
260    // get_attribute("xml:id") read always returned None.)
261    if op == RewriteOperator::Label
262      && let RewritePattern::String(label_str) = &pattern
263    {
264      let as_scope = RewriteClause {
265        compiled: false,
266        op:       RewriteOperator::Scope,
267        pattern:  RewritePattern::String(format!("label:{label_str}")),
268      };
269      return self.compile_clause(document, as_scope);
270    }
271    // scope => 'label:...' compiles to select with xpath via label ID resolution
272    // Perl: $op = 'select'; $pattern = ["descendant-or-self::*[@xml:id='<id>']", 1];
273    if op == RewriteOperator::Scope
274      && let RewritePattern::String(scope_str) = &pattern
275    {
276      // Streaming pass 2: a scope resolving to a fragment ANCESTOR covers
277      // the whole fragment — the scope node itself lives in another
278      // fragment, so the id-xpath below would select nothing (witness
279      // tests/math/simplemath.tex, `label:sec:restricted`).
280      let whole_fragment_scope = |document: &Document| RewriteClause {
281        compiled: true,
282        op:       RewriteOperator::Select,
283        pattern:  RewritePattern::NodeList(
284          document
285            .get_document()
286            .get_root_element()
287            .into_iter()
288            .collect(),
289        ),
290      };
291      if let Some(label_part) = scope_str.strip_prefix("label:") {
292        if let Some(id) = document.lookup_rewrite_label(label_part) {
293          if self.options.select_count.is_none() {
294            self.options.select_count = Some(1);
295          }
296          if document.fragment_ancestor_ids.contains(&id) {
297            return whole_fragment_scope(document);
298          }
299          let xpath = format!("descendant-or-self::*[@xml:id='{}']", id);
300          return RewriteClause {
301            compiled: true,
302            op:       RewriteOperator::Select,
303            pattern:  RewritePattern::String(xpath),
304          };
305        }
306        // Try with LABEL: prefix (clean_label adds it)
307        let clean_key = format!("LABEL:{}", label_part);
308        if let Some(id) = document.lookup_rewrite_label(&clean_key) {
309          if self.options.select_count.is_none() {
310            self.options.select_count = Some(1);
311          }
312          if document.fragment_ancestor_ids.contains(&id) {
313            return whole_fragment_scope(document);
314          }
315          let xpath = format!("descendant-or-self::*[@xml:id='{}']", id);
316          return RewriteClause {
317            compiled: true,
318            op:       RewriteOperator::Select,
319            pattern:  RewritePattern::String(xpath),
320          };
321        }
322        // Label not found. Perl continues with the remaining clauses
323        // unscoped; under streaming that would apply the rule everywhere
324        // (the label usually lives in another fragment), so strict mode
325        // makes the rule inert via an empty scope selection.
326        if document.scoped_rules_strict {
327          return RewriteClause {
328            compiled: true,
329            op:       RewriteOperator::Select,
330            pattern:  RewritePattern::NodeList(Vec::new()),
331          };
332        }
333        return RewriteClause {
334          compiled: true,
335          op:       RewriteOperator::Ignore,
336          pattern:  RewritePattern::String(String::new()),
337        };
338      } else if let Some(id_part) = scope_str.strip_prefix("id:") {
339        if self.options.select_count.is_none() {
340          self.options.select_count = Some(1);
341        }
342        // Use get_property("id") for xml:id lookup (L2 workaround)
343        // findnodes with @xml:id='...' fails in rust-libxml
344        let target_id = id_part.to_string();
345        if document.fragment_ancestor_ids.contains(&target_id) {
346          return whole_fragment_scope(document);
347        }
348        let scope_nodes: Vec<Node> = document
349          .findnodes("descendant-or-self::*", None)
350          .into_iter()
351          .filter(|n| {
352            n.get_property("id").as_deref() == Some(&target_id)
353              || n.get_attribute("xml:id").as_deref() == Some(&target_id)
354          })
355          .collect();
356        if !scope_nodes.is_empty() {
357          // Found the scoped element — use it as the tree root for subsequent clauses
358          return RewriteClause {
359            compiled: true,
360            op:       RewriteOperator::Select,
361            pattern:  RewritePattern::NodeList(scope_nodes),
362          };
363        }
364        // Scope not found — same strict-mode reasoning as the label branch.
365        if document.scoped_rules_strict {
366          return RewriteClause {
367            compiled: true,
368            op:       RewriteOperator::Select,
369            pattern:  RewritePattern::NodeList(Vec::new()),
370          };
371        }
372        return RewriteClause {
373          compiled: true,
374          op:       RewriteOperator::Ignore,
375          pattern:  RewritePattern::String(String::new()),
376        };
377      }
378      return RewriteClause {
379        compiled: true,
380        op:       RewriteOperator::Ignore,
381        pattern:  RewritePattern::String(String::new()),
382      };
383    }
384    // Match compilation:
385    // Perl: match => $code → op='test', pattern=$code (closure returns $nnodes)
386    // Perl: match => $string → op='select', pattern=compile_match1($string) ([$xpath, $nnodes,
387    // @wilds]) Perl: match => [$array] → op='multi_select', pattern=[compile_match1($_) for
388    // @$array]
389    if op == RewriteOperator::Match {
390      match pattern {
391        RewritePattern::String(xpath) => {
392          // Pre-compiled XPath string (from .latexml loader)
393          if self.options.select_count.is_none() {
394            self.options.select_count = Some(1);
395          }
396          return RewriteClause {
397            compiled: true,
398            op:       RewriteOperator::Select,
399            pattern:  RewritePattern::String(xpath),
400          };
401        },
402        RewritePattern::TestClosure(_) => {
403          // match => $code: becomes Test operator
404          return RewriteClause {
405            compiled: true,
406            op: RewriteOperator::Test,
407            pattern,
408          };
409        },
410        RewritePattern::MultiSelectPatterns(_) => {
411          // match => [array]: becomes MultiSelect operator
412          return RewriteClause {
413            compiled: true,
414            op: RewriteOperator::MultiSelect,
415            pattern,
416          };
417        },
418        _ => {},
419      }
420    }
421    RewriteClause { compiled: true, op, pattern }
422  }
423
424  pub fn invoke(&mut self, document: &mut Document, root: &Node) -> Result<()> {
425    let clauses = self.clauses.iter().collect();
426    self.apply_clause(document, root, 0, clauses)?;
427    Ok(())
428  }
429  // Rewrite spec as input
430  //   scope  => $scope  : a scope like "section:1.2.3" or "label:eq.one"; translated to xpath
431  //   select => $xpath  : selects subtrees based on xpath expression.
432  //   match  => $code   : called on $document and current $node: tests current node, returns
433  // $nnodes, if match   match  => $string : Treats as TeX, converts Box, then DOM tree, to xpath
434  //                      (The matching top-level nodes will be replaced, if replace is the next
435  // op.)   replace=> $code   : removes the current $nnodes, calls $code with $document and
436  // removed nodes   replace=> $string : removes $nnodes
437  //                       Treats $string as TeX, converts to Box and inserts to replace
438  //                       the removed nodes.
439  //   attributes=>$hash : adds data from hash as attributes to the current node.
440  //   regexp  => $string: apply regexp (subst) to all text nodes in/under the current node.
441
442  // Compiled rewrite spec:
443  //   select => $xpath  : operate on nodes selected by $xpath.
444  //   test   => $code   : Calls $code on $document and current $node.
445  //                       Returns number of nodes matched.
446  //   replace=> $code   : removes the current $nnodes, calls $code on them.
447  //   action => $code   : invoke $code on current $node, without removing them.
448  //   regexp  => $string: apply regexp (subst) to all text nodes in/under the current node.
449
450  fn apply_clause(
451    &self,
452    document: &mut Document,
453    tree: &Node,
454    nmatched: usize,
455    mut clauses: VecDeque<&RewriteClause>,
456  ) -> Result<()> {
457    use RewriteOperator::*;
458    if let Some(RewriteClause { compiled: _, op, pattern }) = clauses.pop_front() {
459      match op {
460        Select => {
461          // NodeList variant: pre-resolved nodes from scope DOM walking
462          if let RewritePattern::NodeList(nodes) = pattern {
463            for node in nodes {
464              self.apply_clause(document, node, 1, clauses.clone())?;
465            }
466            return Ok(());
467          }
468          if let RewritePattern::String(xpath) = pattern {
469            // Try subtree context first; if 0 results, retry from document root
470            // and filter to descendants of `tree`. This works around rust-libxml
471            // XPath namespace issues when called on a subtree context.
472            let mut matches = document.findnodes(xpath, Some(tree));
473            if matches.is_empty() && !xpath.contains("xml:id") && !xpath.contains("@id=") {
474              let all = document.findnodes(xpath, None);
475              if !all.is_empty() {
476                let tree_ptr = tree.node_ptr();
477                matches = all
478                  .into_iter()
479                  .filter(|n| {
480                    let mut cur = n.get_parent();
481                    while let Some(p) = cur {
482                      if std::ptr::eq(p.node_ptr(), tree_ptr) {
483                        return true;
484                      }
485                      cur = p.get_parent();
486                    }
487                    false
488                  })
489                  .collect();
490              }
491            }
492            // Only apply wildcard filtering on content Selects, not scope Selects
493            let is_content_select = !xpath.contains("xml:id") && !xpath.contains("@id=");
494            let wilds = if is_content_select {
495              self.options.wildcard_paths.clone()
496            } else {
497              None
498            };
499            // Declare-side structural filter (content Selects only, not
500            // scope Selects): verify each broad-XPath match against the
501            // compiled pattern.
502            let pattern_filter = if is_content_select {
503              self.options.declare_filter.as_ref()
504            } else {
505              None
506            };
507            for node in matches {
508              if node.has_attribute("_matched") {
509                continue;
510              }
511              if let Some(pat) = pattern_filter
512                && !declare::declare_node_matches(document, &node, pat)
513              {
514                continue;
515              }
516              let marked = if let Some(ref wpaths) = wilds {
517                mark_wildcards(&node, wpaths)
518              } else {
519                vec![]
520              };
521              // Scope Selects always pass nmatched=1; content Selects use select_count
522              let nmatched_for_clause = if is_content_select {
523                self.options.select_count.unwrap_or(1)
524              } else {
525                1
526              };
527              self.apply_clause(document, &node, nmatched_for_clause, clauses.clone())?;
528              if !marked.is_empty() {
529                unmark_wildcards(&marked);
530              }
531            }
532          }
533        },
534        Replace => {
535          // Perl Rewrite.pm L122 uses `$tree->parentNode` with no check —
536          // XML::LibXML returns undef silently and subsequent operations
537          // (lastChild / childNodes) on undef are no-ops in practice,
538          // effectively skipping root-level rewrites. In Rust we have to
539          // be explicit: if the matched node is detached or is the root,
540          // there's no parent tree structure to splice into, so skip the
541          // clause just as Perl's no-op degrades to.
542          let Some(mut parent) = tree.get_parent() else {
543            return Ok(());
544          };
545          // Remove & separate nodes to be replaced, and sibling nodes following them.
546          let mut following = VecDeque::new(); // Collect the matching and following nodes
547          while let Some(mut sib) = parent.get_last_child() {
548            sib.unbind_node();
549            if *tree == sib {
550              following.push_front(sib);
551              break;
552            } else {
553              following.push_front(sib);
554            }
555          }
556          let mut replaced = Vec::new();
557          for _idx in 0..nmatched {
558            // Remove the nodes to be replaced
559            match following.pop_front() {
560              Some(popped) => {
561                replaced.push(popped);
562              },
563              _ => {
564                break; // nmatched larger than available nodes — stop
565              },
566            }
567          }
568          for rnode in replaced.iter() {
569            document.unrecord_node_ids(rnode);
570          }
571          // Carry out the operation, inserting whatever nodes.
572          document.set_node(&parent);
573          let point_opt = parent.get_last_child();
574          if let RewritePattern::Closure(closure) = pattern {
575            closure(document, replaced.iter_mut().collect())?; // Carry out the insertion.
576          }
577
578          // Now collect the newly inserted nodes for any needed patching
579          let inserted = if let Some(point) = point_opt {
580            let mut ins_queue = VecDeque::new();
581            let mut sibs = parent.get_child_nodes();
582            while let Some(sib) = sibs.pop() {
583              if sib == point {
584                break;
585              }
586              ins_queue.push_front(sib);
587            }
588            ins_queue.into_iter().collect::<Vec<Node>>()
589          } else {
590            parent.get_child_nodes()
591          };
592
593          // Now make any adjustments to the new nodes
594          for ins in inserted.iter() {
595            document.record_node_ids(ins)?;
596          }
597          // TODO: Can we avoid this clone?
598          let font = document.get_node_font(tree).clone();
599          // the font of the matched node
600          for ins in inserted.iter() {
601            // Copy the non-semantic parts of font to the replacement
602            document.merge_node_font_rec(ins, &font)?;
603          }
604          // Now, replace the following nodes.
605          for mut follow_node in following {
606            parent.add_child(&mut follow_node)?;
607          }
608        },
609        Attributes => {
610          if let Some(ref attrs) = self.options.attributes_map {
611            let has_wc = tree.has_attribute("_has_wildcards");
612            if has_wc {
613              // Perl: setAttributes_wild — wildcards present in matched tree
614              let mut nodes = vec![tree.clone()];
615              // Collect nmatched siblings
616              let mut cur = tree.clone();
617              for _ in 1..nmatched {
618                match cur.get_next_sibling() {
619                  Some(sib) => {
620                    cur = sib.clone();
621                    nodes.push(sib);
622                  },
623                  _ => {
624                    break;
625                  },
626                }
627              }
628              set_attributes_wild(document, attrs, nodes, nmatched)?;
629            } else if nmatched > 1 {
630              // Multi-node: collect nmatched element siblings starting from tree
631              let mut nodes = vec![tree.clone()];
632              let mut cur = tree.clone();
633              for _ in 1..nmatched {
634                while let Some(sib) = cur.get_next_sibling() {
635                  cur = sib.clone();
636                  if sib.get_type() == Some(libxml::tree::NodeType::ElementNode) {
637                    nodes.push(sib);
638                    break;
639                  }
640                }
641              }
642              // Perl: skip if ALL nodes already matched
643              if nodes.iter().any(|n| !n.has_attribute("_matched"))
644                && let Ok(Some(mut wrapper)) = document.wrap_nodes("ltx:XMWrap", nodes)
645              {
646                for (key, value) in attrs {
647                  if !key.starts_with('_') {
648                    let _ = wrapper.set_attribute(key, value);
649                  }
650                }
651                let _ = wrapper.set_attribute("_rewrite", "1");
652              }
653            } else if !tree.has_attribute("_matched") {
654              // Single node: set attributes directly
655              let mut node = tree.clone();
656              for (key, value) in attrs {
657                if !key.starts_with('_') {
658                  let _ = node.set_attribute(key, value);
659                }
660              }
661              if node.get_name() == "XMApp" && attrs.contains_key("role") {
662                let _ = node.set_attribute("_rewrite", "1");
663              }
664            }
665          }
666          mark_seen(tree, nmatched);
667          self.apply_clause(document, tree, nmatched, clauses)?;
668        },
669        Ignore => {
670          // Perl: $self->applyClause($document, $tree, $nmatched, @more_clauses);
671          self.apply_clause(document, tree, nmatched, clauses)?;
672        },
673        Regexp => {
674          // Perl: finds ALL descendant text nodes via 'descendant-or-self::text()',
675          // applies s///g substitution via compiled closure, and calls setData().
676          if let RewritePattern::RegexpClosure(closure) = pattern {
677            let text_nodes = document.findnodes("descendant-or-self::text()", Some(tree));
678            for mut text_node in text_nodes {
679              let content = text_node.get_content();
680              if let Some(modified) = closure(&content) {
681                let _ = text_node.set_content(&modified);
682              }
683            }
684          } else if let RewritePattern::String(regex_str) = pattern {
685            // Fallback for uncompiled string regex: compile and apply as substitution
686            let re =
687              regex::Regex::new(regex_str).unwrap_or_else(|_| regex::Regex::new("$^").unwrap());
688            let text_nodes = document.findnodes("descendant-or-self::text()", Some(tree));
689            for mut text_node in text_nodes {
690              let content = text_node.get_content();
691              let result = re.replace_all(&content, "");
692              if result != content {
693                let _ = text_node.set_content(&result);
694              }
695            }
696          }
697        },
698        Label => {
699          // Unreachable once compiled: compile_clause lowers Label to a
700          // Select via the scope "label:…" path (Perl Rewrite.pm L288-297 —
701          // a label clause has NO runtime behavior). Defensive pass-through.
702          self.apply_clause(document, tree, nmatched, clauses)?;
703        },
704        Trace => {
705          // Debug tracing — just continue
706          self.apply_clause(document, tree, nmatched, clauses)?;
707        },
708        Action => {
709          // Perl: $code->($document, $tree, $nmatched)
710          // Action invokes a closure on the matched node without removing it.
711          if let RewritePattern::Closure(closure) = pattern {
712            let mut node = tree.clone();
713            closure(document, vec![&mut node])?;
714          }
715          // Continue with remaining clauses
716          self.apply_clause(document, tree, nmatched, clauses)?;
717        },
718        Test => {
719          // Perl: $nnodes = &$pattern($document, $tree);
720          //       $self->applyClause($document, $tree, $nnodes, @more_clauses) if $nnodes;
721          // Test closure returns node count; if 0/falsy, skip remaining clauses.
722          if let RewritePattern::TestClosure(closure) = pattern {
723            let nnodes = closure(document, tree)?;
724            if nnodes > 0 {
725              self.apply_clause(document, tree, nnodes, clauses)?;
726            }
727          } else if let RewritePattern::Closure(closure) = pattern {
728            // Legacy fallback: RewriteReplaceClosure used as test (always passes)
729            let mut node = tree.clone();
730            closure(document, vec![&mut node])?;
731            self.apply_clause(document, tree, nmatched, clauses)?;
732          }
733        },
734        MultiSelect => {
735          // Perl: foreach my $subpattern (@$pattern) {
736          //         my ($xpath, $nnodes, @wilds) = @$subpattern;
737          //         my @matches = $document->findnodes($xpath, $tree);
738          //         foreach my $node (@matches) {
739          //           my @w = markWildcards($node, @wilds);
740          //           $self->applyClause($document, $node, $nnodes, @more_clauses);
741          //           unmarkWildcards($node, @w); } }
742          if let RewritePattern::MultiSelectPatterns(entries) = pattern {
743            for entry in entries {
744              let matches = document.findnodes(&entry.xpath, Some(tree));
745              for node in matches {
746                if node.has_attribute("_matched") {
747                  continue;
748                }
749                let marked = if !entry.wilds.is_empty() {
750                  mark_wildcards(&node, &entry.wilds)
751                } else {
752                  vec![]
753                };
754                self.apply_clause(document, &node, entry.nnodes, clauses.clone())?;
755                if !marked.is_empty() {
756                  unmark_wildcards(&marked);
757                }
758              }
759            }
760          } else if let RewritePattern::String(xpath) = pattern {
761            // Legacy fallback: single xpath with shared select_count
762            let count = self.options.select_count.unwrap_or(1);
763            let matches = document.findnodes(xpath, Some(tree));
764            for node in matches {
765              if node.has_attribute("_matched") {
766                continue;
767              }
768              self.apply_clause(document, &node, count, clauses.clone())?;
769            }
770          }
771        },
772        _ => {
773          // Remaining unimplemented operators — skip silently
774          self.apply_clause(document, tree, nmatched, clauses)?;
775        },
776      }
777    } else {
778      // No more clauses — mark the matched nodes as seen
779      // Perl: markSeen($tree, $nmatched) when no more clauses
780      mark_seen(tree, nmatched);
781    }
782
783    Ok(())
784  }
785}
786
787// ======================================================================
788// WildCard support: domToXPath, wildcard marking, XMDual wrapping
789// ======================================================================
790
791/// Wildcard position path: indices to navigate from matched root to wildcard node.
792/// First index uses nth_sibling (sibling offset), rest use nth_child (child position).
793pub type WildPath = Vec<usize>;
794
795/// Result of domToXPath compilation: xpath string, node count, wildcard paths.
796pub type CompiledMatch = (String, usize, Vec<WildPath>);
797
798/// Convert a DOM subtree to an XPath expression + wildcard position tracking.
799/// Perl: domToXPath() → domToXPath_rec() → domToXPath_seq()
800pub fn dom_to_xpath(document: &Document, node: &Node) -> CompiledMatch {
801  let (xpath, nnodes, _nwilds, wilds) =
802    dom_to_xpath_rec(document, node, "descendant-or-self", None);
803  (xpath, nnodes, wilds)
804}
805
806/// Attributes excluded from XPath match predicates.
807fn is_excluded_match_attr(key: &str) -> bool {
808  matches!(
809    key,
810    "scriptpos" | "mathstyle" | "xml:id" | "fontsize" | "_font" | "_pvis" | "_cvis"
811  ) || key.starts_with('_')
812}
813
814/// Recursive DOM-to-XPath conversion.
815/// Returns (xpath_fragment, node_count, wildcard_count, wildcard_paths)
816fn dom_to_xpath_rec(
817  document: &Document,
818  node: &Node,
819  axis: &str,
820  pos: Option<usize>,
821) -> (String, usize, usize, Vec<WildPath>) {
822  let node_type = node.get_type();
823  // NodeList / DocumentFragment: sequence of children
824  if node_type == Some(libxml::tree::NodeType::DocumentFragNode) {
825    let children = node.get_child_nodes();
826    let (xpath, nnodes, wilds) = dom_to_xpath_seq(document, axis, pos, &children);
827    return (xpath, nnodes, 0, wilds);
828  }
829  if node_type == Some(libxml::tree::NodeType::ElementNode) {
830    let qname = arena::with(get_node_qname(node), |s| s.to_string());
831    let children = node.get_child_nodes();
832
833    // _WildCard_ element → matches anything
834    if qname == "_WildCard_" {
835      if !children.is_empty() {
836        // WildCard WITH children: recurse on children
837        let child_list = node.get_child_nodes();
838        // Create a fragment-like approach: process children as a sequence
839        let (xpath, _nnodes, _nwilds, _wilds) =
840          dom_to_xpath_rec(document, &child_list[0], axis, pos);
841        let n = children.len().max(1);
842        return (xpath, n, n, vec![]);
843      } else {
844        return (format!("{axis}::*"), 1, 1, vec![]);
845      }
846    }
847    // XMRef pointing to a _WildCard_ is also a wildcard
848    if qname == "ltx:XMRef"
849      && let Some(idref) = node.get_property("idref")
850      && let Some(target) = document.lookup_id(&idref).cloned()
851    {
852      let tqname = arena::with(get_node_qname(&target), |s| s.to_string());
853      // Check if target is XMArg/XMWrap with single WildCard child
854      let is_wild = if tqname.ends_with("XMArg") || tqname.ends_with("XMWrap") {
855        let tc = target.get_child_nodes();
856        tc.len() == 1 && arena::with(get_node_qname(&tc[0]), |s| s == "_WildCard_")
857      } else {
858        tqname == "_WildCard_"
859      };
860      if is_wild {
861        return (format!("{axis}::*"), 1, 1, vec![]);
862      }
863    }
864    // XMArg/XMWrap with single _WildCard_ child
865    if (qname.ends_with("XMArg") || qname.ends_with("XMWrap"))
866      && children.len() == 1
867      && arena::with(get_node_qname(&children[0]), |s| s.to_string()) == "_WildCard_"
868    {
869      let wc_children = children[0].get_child_nodes();
870      if !wc_children.is_empty() {
871        let (child_xpath, _nn, _nw, _w) =
872          dom_to_xpath_rec(document, &wc_children[0], "child", Some(1));
873        let mut preds = vec![];
874        if let Some(p) = pos {
875          preds.push(format!("position()={p}"));
876        }
877        preds.push(child_xpath);
878        return (
879          format!("{axis}::{qname}[{}]", preds.join(" and ")),
880          1,
881          1,
882          vec![],
883        );
884      } else {
885        return (format!("{axis}::*"), 1, 1, vec![]);
886      }
887    }
888
889    // Standard element: build predicates from attributes and children
890    let mut predicates = Vec::new();
891    let mut wilds = Vec::new();
892
893    // Attribute predicates
894    let attrs = node.get_attributes();
895    for (key, value) in &attrs {
896      if !is_excluded_match_attr(key) {
897        predicates.push(format!("@{key}='{}'", value.replace('\'', "&apos;")));
898      }
899    }
900    // Child predicates
901    if !children.is_empty() {
902      let all_text = children
903        .iter()
904        .all(|c| c.get_type() == Some(libxml::tree::NodeType::TextNode));
905      let all_elem = children
906        .iter()
907        .all(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode));
908      if all_text {
909        let text = node.get_content();
910        predicates.push(format!("text()='{}'", text.replace('\'', "&apos;")));
911      } else if all_elem {
912        let (xp, _nn, w) = dom_to_xpath_seq(document, "child", Some(1), &children);
913        predicates.push(xp);
914        wilds.extend(w);
915      }
916      // Mixed content: skip (rare in math patterns)
917    }
918
919    // Position-based matching (when this is a child in a sequence)
920    let tag = if let Some(p) = pos {
921      predicates.insert(0, format!("self::{qname}"));
922      predicates.insert(0, format!("position()={p}"));
923      "*".to_string()
924    } else {
925      qname
926    };
927    let preds = predicates.join(" and ");
928    let xpath = if preds.is_empty() {
929      format!("{axis}::{tag}")
930    } else {
931      format!("{axis}::{tag}[{preds}]")
932    };
933    return (xpath, 1, 0, wilds);
934  }
935  if node_type == Some(libxml::tree::NodeType::TextNode) {
936    let text = node.get_content();
937    return (
938      format!("*[text()='{}']", text.replace('\'', "&apos;")),
939      1,
940      0,
941      vec![],
942    );
943  }
944  (String::new(), 0, 0, vec![])
945}
946
947/// Convert a sequence of sibling nodes to XPath with wildcard tracking.
948/// Perl: domToXPath_seq()
949fn dom_to_xpath_seq(
950  document: &Document,
951  axis: &str,
952  pos: Option<usize>,
953  nodes: &[Node],
954) -> (String, usize, Vec<WildPath>) {
955  if nodes.is_empty() {
956    return (String::new(), 0, vec![]);
957  }
958  let mut i: usize = 1;
959  let mut sib_xpaths = Vec::new();
960  let mut wilds = Vec::new();
961
962  // First node
963  let (xpath, _nn, nwilds, w0) = dom_to_xpath_rec(document, &nodes[0], axis, pos);
964  if nwilds > 0 {
965    for _ in 0..nwilds {
966      wilds.push(vec![i]);
967      i += 1;
968    }
969  } else {
970    for w in &w0 {
971      let mut path = vec![1usize];
972      path.extend(w);
973      wilds.push(path);
974    }
975    i += 1;
976  }
977  // Remaining siblings
978  for sib in &nodes[1..] {
979    let (xp, _nn, nw, w) = dom_to_xpath_rec(document, sib, "following-sibling", Some(i - 1));
980    sib_xpaths.push(xp);
981    if nw > 0 {
982      for _ in 0..nw {
983        wilds.push(vec![i]);
984        i += 1;
985      }
986    } else {
987      for ww in &w {
988        let mut path = vec![i];
989        path.extend(ww);
990        wilds.push(path);
991      }
992      i += 1;
993    }
994  }
995  let mut result = xpath;
996  for sp in &sib_xpaths {
997    result = format!("{result}[{sp}]");
998  }
999  (result, i - 1, wilds)
1000}
1001
1002/// Navigate to the nth sibling (1-based) from a starting node.
1003fn nth_sibling(node: &Node, n: usize) -> Option<Node> {
1004  let mut current = Some(node.clone());
1005  for _ in 1..n {
1006    current = current.and_then(|n| {
1007      let mut next = n.get_next_sibling();
1008      // Skip non-element nodes
1009      while let Some(ref s) = next {
1010        if s.get_type() == Some(libxml::tree::NodeType::ElementNode) {
1011          break;
1012        }
1013        next = s.get_next_sibling();
1014      }
1015      next
1016    });
1017  }
1018  current
1019}
1020
1021/// Navigate to the nth child (1-based) of a node.
1022fn nth_child(node: &Node, n: usize) -> Option<Node> {
1023  node.get_child_nodes().into_iter().nth(n - 1)
1024}
1025
1026/// Mark wildcard nodes in the matched tree.
1027/// Perl: markWildcards($node, @wilds)
1028pub fn mark_wildcards(node: &Node, wilds: &[WildPath]) -> Vec<Node> {
1029  if wilds.is_empty() {
1030    return vec![];
1031  }
1032  let mut n = node.clone();
1033  let _ = n.set_attribute("_has_wildcards", "1");
1034  let mut marked = Vec::new();
1035  for wild in wilds {
1036    let mut current = Some(node.clone());
1037    let mut first = true;
1038    for &idx in wild {
1039      if current.is_none() {
1040        break;
1041      }
1042      current = if first {
1043        first = false;
1044        nth_sibling(current.as_ref().unwrap(), idx)
1045      } else {
1046        nth_child(current.as_ref().unwrap(), idx)
1047      };
1048    }
1049    if let Some(ref c) = current
1050      && c.get_type() == Some(libxml::tree::NodeType::ElementNode)
1051    {
1052      let mut mc = c.clone();
1053      let _ = mc.set_attribute("_wildcard", "1");
1054      marked.push(mc);
1055    }
1056  }
1057  marked
1058}
1059
1060/// Unmark wildcard nodes after processing.
1061pub fn unmark_wildcards(nodes: &[Node]) {
1062  for n in nodes {
1063    if n.get_type() == Some(libxml::tree::NodeType::ElementNode) {
1064      let mut mc = n.clone();
1065      let _ = mc.remove_attribute("_has_wildcards");
1066      let _ = mc.remove_attribute("_wildcard");
1067    }
1068  }
1069}
1070
1071/// Collect xml:ids of wildcard-marked nodes, generating IDs if needed.
1072/// Collect xml:ids of wildcard-marked nodes, generating IDs if needed.
1073/// Perl: set_wildcard_ids($document, $node) — Rewrite.pm L219-231
1074/// Faithfully translated: if node has _wildcard, return its ID.
1075/// If node has _matched, skip (already processed by prior rule).
1076/// Otherwise recurse into children.
1077pub fn set_wildcard_ids(document: &mut Document, node: &Node) -> Vec<String> {
1078  if node.get_type() != Some(libxml::tree::NodeType::ElementNode) {
1079    return vec![];
1080  }
1081  if node.has_attribute("_matched") {
1082    return vec![];
1083  }
1084  if node.has_attribute("_wildcard") {
1085    // Perl: unconditionally returns the wildcard's ID.
1086    // Even if all descendants are already matched, the ID is still needed
1087    // for XMRef in the content arm. pruneXMDuals handles collapsing later.
1088    let id = if let Some(existing) = node
1089      .get_property("xml:id")
1090      .or_else(|| node.get_property("id"))
1091    {
1092      existing
1093    } else {
1094      // Generate an ID for this node
1095      let mut n = node.clone();
1096      let _ = document.generate_id(&mut n, "");
1097      node
1098        .get_property("xml:id")
1099        .or_else(|| node.get_property("id"))
1100        .unwrap_or_default()
1101    };
1102    return vec![id];
1103  }
1104  // Recurse into children
1105  let mut ids = Vec::new();
1106  for child in node.get_child_nodes() {
1107    ids.extend(set_wildcard_ids(document, &child));
1108  }
1109  ids
1110}
1111
1112/// Set attributes on a tree containing wildcards, creating XMDual wrappers.
1113/// Perl: setAttributes_wild($document, $attributes, @nodes) — Rewrite.pm L195-217
1114///
1115/// Faithfully translated from Perl. The structure created is:
1116/// ```xml
1117/// <XMDual role="...">
1118///   <XMApp>                    <!-- content arm -->
1119///     <XMTok decl_id="..." />  <!-- semantic operator -->
1120///     <XMRef idref="..." />    <!-- references to wildcards -->
1121///   </XMApp>
1122///   <XMWrap>                   <!-- presentation arm -->
1123///     [original nodes]         <!-- with _wildcard markers -->
1124///   </XMWrap>
1125/// </XMDual>
1126/// ```
1127pub fn set_attributes_wild(
1128  document: &mut Document,
1129  attrs: &HashMap<String, String>,
1130  nodes: Vec<Node>,
1131  _nmatched: usize,
1132) -> Result<()> {
1133  // Perl L197: return unless grep { !$_->getAttribute('_matched'); } @nodes;
1134  if nodes.iter().all(|n| n.has_attribute("_matched")) {
1135    return Ok(());
1136  }
1137  let nowrap = attrs.contains_key("_nowrap");
1138  // Perl L199-203: _nowrap or single XMDual → set attrs on first non-wildcard node
1139  if nowrap || (nodes.len() == 1 && nodes[0].get_name() == "XMDual") {
1140    if let Some(nonwild) = nodes.iter().find(|n| !n.has_attribute("_wildcard")) {
1141      let mut n = nonwild.clone();
1142      for (key, value) in attrs {
1143        if !key.starts_with('_') {
1144          let _ = n.set_attribute(key, value);
1145        }
1146      }
1147    }
1148    return Ok(());
1149  }
1150
1151  // Perl L205-216: wrap the presentation nodes in an XMWrap, collect the
1152  // wildcard ids, wrap that in an XMDual, then build the content arm
1153  // XMApp(op-with-attrs, XMRef per wildcard) BEFORE the XMWrap. An XMDual
1154  // has EXACTLY two children (content, presentation) — the earlier "flat"
1155  // variant (presentation nodes as direct dual children) was destroyed
1156  // downstream, silently dropping the matched span (gee-one repro:
1157  // `g(a)` collapsed to a bare `)` carrying the declaration's role).
1158  let Some(wrap_node) = document.wrap_nodes("ltx:XMWrap", nodes)? else {
1159    return Ok(());
1160  };
1161  let wild_ids = set_wildcard_ids(document, &wrap_node);
1162  let Some(mut dual_node) = document.wrap_nodes("ltx:XMDual", vec![wrap_node.clone()])? else {
1163    return Ok(());
1164  };
1165
1166  // Set role on XMDual (Perl L209)
1167  if let Some(role) = attrs.get("role") {
1168    let _ = dual_node.set_attribute("role", role);
1169  }
1170
1171  // Build content arm: XMApp > XMTok[attrs] + XMRef[wildcard_ids]
1172  let doc = document.get_document();
1173  let mut content_app = Node::new("XMApp", None, doc)?;
1174  let mut content_op = Node::new("XMTok", None, doc)?;
1175  for (key, value) in attrs {
1176    if key != "role" && !key.starts_with('_') {
1177      let _ = content_op.set_attribute(key, value);
1178    }
1179  }
1180  content_app.add_child(&mut content_op)?;
1181  for rid in &wild_ids {
1182    let mut xmref = Node::new("XMRef", None, doc)?;
1183    let _ = xmref.set_attribute("idref", rid);
1184    content_app.add_child(&mut xmref)?;
1185  }
1186
1187  // Insert content arm before the presentation XMWrap (Perl removes the
1188  // wrapper, opens the XMApp, then re-appends the wrapper — same order).
1189  let mut wrap_mut = wrap_node;
1190  wrap_mut.add_prev_sibling(&mut content_app)?;
1191  Ok(())
1192}
1193
1194/// Mark a node (and nsibs following siblings) as matched, preventing re-matching.
1195/// Perl: markSeen($node, $nsibs) + markSeen_rec($node)
1196fn mark_seen(node: &Node, nsibs: usize) {
1197  let mut current = Some(node.clone());
1198  for _i in 0..nsibs {
1199    if let Some(n) = current {
1200      mark_seen_rec(&n);
1201      current = n.get_next_sibling();
1202    } else {
1203      break;
1204    }
1205  }
1206}
1207
1208fn mark_seen_rec(node: &Node) {
1209  if node.has_attribute("_wildcard") {
1210    return;
1211  }
1212  let mut n = node.clone();
1213  let _ = n.set_attribute("_matched", "1");
1214  for child in node.get_child_nodes() {
1215    if child.get_type() == Some(libxml::tree::NodeType::ElementNode) {
1216      mark_seen_rec(&child);
1217    }
1218  }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223  use super::*;
1224  use crate::document::Document;
1225
1226  /// A `label` clause must COMPILE AWAY into a Select on the labeled node's
1227  /// `xml:id` — Perl `Rewrite.pm::compileClause` L288-297:
1228  /// `$op = 'select'; $pattern = ["descendant-or-self::*[\@xml:id='" .
1229  ///  $self->getLabelID($pattern) . "']", 1];`
1230  ///
1231  /// It has no runtime behavior in Perl. The former Rust runtime arm instead
1232  /// tried to RECORD the current node's id under the label — not Perl
1233  /// semantics, and dead anyway (its bare `get_attribute("xml:id")` read
1234  /// always returned None). `label => …` is reachable only from the Rhai
1235  /// `DefRewrite(#{label: …})` option bag, so this unit test is its only
1236  /// coverage.
1237  #[test]
1238  fn label_clause_compiles_to_a_select_on_the_labeled_id() {
1239    let mut document = Document::new();
1240    document
1241      .rewrite_labels
1242      .insert("LABEL:eq.one".to_string(), "S1.E2".to_string());
1243
1244    let mut rule = Rewrite::new("text", RewriteOptions {
1245      label: Some("eq.one".to_string()),
1246      ..RewriteOptions::default()
1247    });
1248    assert!(
1249      matches!(
1250        rule.clauses.first().map(|c| c.op),
1251        Some(RewriteOperator::Label)
1252      ),
1253      "a label option must start life as a Label clause"
1254    );
1255    rule.compile_clauses(&mut document);
1256
1257    match rule.clauses.first() {
1258      Some(RewriteClause {
1259        op: RewriteOperator::Select,
1260        pattern: RewritePattern::String(xpath),
1261        ..
1262      }) => assert_eq!(xpath, "descendant-or-self::*[@xml:id='S1.E2']"),
1263      other => panic!("label must lower to a Select on the labeled id, got {other:?}"),
1264    }
1265    // Perl sets the select count to 1 in the same breath (L297's trailing `1`).
1266    assert_eq!(rule.options.select_count, Some(1));
1267  }
1268
1269  /// An UNKNOWN label falls through to `Ignore`, so the remaining clauses
1270  /// still apply to the current tree — preserving the pre-lowering behavior
1271  /// on a miss (Perl errors and yields an empty-id xpath, which selects
1272  /// nothing; the strict streaming path mirrors that with an empty NodeList).
1273  #[test]
1274  fn unknown_label_falls_through_to_ignore() {
1275    let mut document = Document::new();
1276    let mut rule = Rewrite::new("text", RewriteOptions {
1277      label: Some("nope".to_string()),
1278      ..RewriteOptions::default()
1279    });
1280    rule.compile_clauses(&mut document);
1281    assert!(
1282      matches!(
1283        rule.clauses.first().map(|c| c.op),
1284        Some(RewriteOperator::Ignore)
1285      ),
1286      "an unresolvable label must not silently restrict the rule"
1287    );
1288  }
1289}