Skip to main content

latexml_core/binding/def/
replacement.rs

1//! Shared XML-replacement template AST + parser + runtime interpreter (#171).
2//!
3//! A constructor's `"<ltx:…>"` replacement is a tiny templating language (see the
4//! grammar comment in `latexml_codegen::constructable`). Historically it had **two**
5//! independent implementations: the compile-time proc-macro
6//! (`latexml_codegen/src/constructable.rs`, a regex-strip state machine fused to
7//! codegen) and the runtime byte-scanner (`latexml_contrib::script_bindings`).
8//! Two implementations of one language is drift waiting to happen.
9//!
10//! This module is the single source of truth: one [`winnow`] parser produces one
11//! [`ReplacementOp`] AST, consumed by **both** front-ends —
12//! * the compile-time codegen walks `&[ReplacementOp]` and emits `quote!`,
13//! * the runtime interpreter [`apply_ops`] walks the same AST against a live `Document`.
14//!
15//! The semantics mirror the Perl `LaTeXML::Core::Definition::Constructor::Compiler`
16//! (`Compiler.pm`) faithfully — the existing `constructable.rs` is the ground
17//! truth this reproduces, including its quirks (see [`unquote`]). The dialect:
18//!
19//! ```text
20//!  #1..#9            n-th digested argument                 (Value::Arg)
21//!  #name             named whatsit property                 (Value::Prop)
22//!  &func(args,…)     function call (whitelisted at runtime)  (Value::Func)
23//!  <q a='v' …>       open element + attributes              (OpenElement)
24//!  <q … />           empty element (open + close)
25//!  </q>              close element                          (CloseElement)
26//!  <?q a='v' …?>     processing instruction                 (ProcessingInstruction)
27//!  ?test(if)(else)   conditional                            (Conditional)
28//!  ^ / ^^  prefix    float the next element/attribute       (FloatKind)
29//!  key='v'           set attribute on current node          (SetAttribute)
30//!  literal text      absorb as a string                     (Text)
31//! ```
32
33use std::borrow::Cow;
34
35use libxml::tree::Node;
36use rustc_hash::FxHashMap as HashMap;
37use winnow::{
38  combinator::{alt, opt, peek, repeat},
39  error::{ContextError, ErrMode},
40  prelude::*,
41  token::{literal, one_of, take_while},
42};
43
44use crate::{
45  common::{
46    arena::SymHashMap,
47    error::{Error, Result},
48    font::Font,
49    store::Stored,
50  },
51  definition::FontDirective,
52  digested::Digested,
53  document::Document,
54};
55
56// ────────────────────────────── AST ──────────────────────────────
57
58/// A `^` / `^^` float prefix attaching to the next open-element. `^` floats to
59/// where the element is allowed; `^^` additionally closes intervening open
60/// elements if possible (Perl Compiler.pm float_type 1 vs 2). Counts ≥2 collapse
61/// to `Double` (counts ≥3 never occur in practice).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FloatKind {
64  Single,
65  Double,
66}
67
68/// A substitutable value: `#n`, `#name`, `&func(…)`, or literal text. Literal only
69/// arises inside function arguments / attribute strings — never at content
70/// position (a bare literal there is [`ReplacementOp::Text`]).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Value {
73  /// `#n` (1..=9) — the n-th digested argument (1-based).
74  Arg(usize),
75  /// `#name` — a named whatsit property.
76  Prop(String),
77  /// `&func(args…)` — a function call (resolved through a whitelist at runtime,
78  /// a Rust call at compile time).
79  Func { name: String, args: Vec<FuncArg> },
80  /// Literal text, already `unquote`d.
81  Literal(String),
82}
83
84/// A `&func(…)` argument — either a bare value or a quoted interpolated string.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum FuncArg {
87  Value(Value),
88  Str(AttrValue),
89}
90
91/// An interpolated attribute value: `'role-#1'` → `[Literal("role-"), Value(Arg 1)]`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct AttrValue {
94  pub parts: Vec<AttrPart>,
95}
96
97/// One piece of an [`AttrValue`].
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum AttrPart {
100  Literal(String),
101  Value(Value),
102  /// `?test(ifval)(elseval)` inside a quoted attribute string — branches are
103  /// single values (Perl `translate_string`).
104  Conditional {
105    test:     Value,
106    then_val: Value,
107    else_val: Value,
108  },
109}
110
111/// An attribute-list entry inside a `<tag …>` or `<?pi …?>` — a key/value pair or
112/// a conditional set of pairs (Perl `translate_avpairs`).
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum AttrPair {
115  KeyValue {
116    key:   String,
117    value: AttrValue,
118  },
119  Conditional {
120    test:       Value,
121    then_attrs: Vec<AttrPair>,
122    else_attrs: Vec<AttrPair>,
123  },
124}
125
126/// One operation in a compiled replacement template.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum ReplacementOp {
129  /// `<q a='v' …>` (or `<q … />` when `self_closing`). `float` set when a `^`/`^^`
130  /// prefix attaches here.
131  OpenElement {
132    qname:        String,
133    attrs:        Vec<AttrPair>,
134    float:        Option<FloatKind>,
135    self_closing: bool,
136  },
137  /// `</q>`.
138  CloseElement { qname: String },
139  /// `<?q a='v' …?>`.
140  ProcessingInstruction { qname: String, attrs: Vec<AttrPair> },
141  /// `#n` / `#name` / `&func(…)` at content position — absorb the value.
142  AbsorbValue { value: Value },
143  /// `key='v'` at content position — set the attribute on the current node.
144  /// `float` set when a `^` prefix attaches here.
145  SetAttribute {
146    key:   String,
147    value: AttrValue,
148    float: bool,
149  },
150  /// Literal text to absorb.
151  Text { text: String },
152  /// `?test(if)(else)` — branches are op-lists.
153  Conditional {
154    test:     Value,
155    then_ops: Vec<ReplacementOp>,
156    else_ops: Vec<ReplacementOp>,
157  },
158}
159
160// ───────────────────────────── parser ─────────────────────────────
161
162/// Parse an XML-replacement template into a [`ReplacementOp`] op-list. The entry
163/// point for both consumers (codegen at compile time, [`apply_ops`] at runtime).
164pub fn parse_replacement(template: &str) -> Result<Vec<ReplacementOp>> {
165  ops_with_float
166    .parse(template)
167    .map_err(|e| Error::from(format!("replacement template parse error: {e}")))
168}
169
170/// A leading `^`/`^^` float prefix, then a sequence of ops, with the float
171/// attached to the first open-element / top-level attribute that follows. Used as
172/// the top-level parser and for conditional op-branches (each re-parsed
173/// independently, mirroring Perl's recursive `compile_replacement_tokens`).
174fn ops_with_float(input: &mut &str) -> ModalResult<Vec<ReplacementOp>> {
175  let float = opt(float_prefix).parse_next(input)?;
176  let mut ops: Vec<ReplacementOp> = repeat(0.., op).parse_next(input)?;
177  if let Some(fk) = float {
178    attach_float(&mut ops, fk);
179  }
180  Ok(ops)
181}
182
183/// Attach a leading float to the first open-element / top-level attribute. Scans
184/// only the top-level op list (a float does not reach into conditional branches).
185fn attach_float(ops: &mut [ReplacementOp], fk: FloatKind) {
186  for o in ops.iter_mut() {
187    match o {
188      ReplacementOp::OpenElement { float, .. } => {
189        *float = Some(fk);
190        return;
191      },
192      ReplacementOp::SetAttribute { float, .. } => {
193        *float = true;
194        return;
195      },
196      _ => {},
197    }
198  }
199}
200
201/// `^+ \s*` — one or more carets then whitespace (Perl `FLOAT_RE`).
202fn float_prefix(input: &mut &str) -> ModalResult<FloatKind> {
203  let carets = take_while(1.., '^').parse_next(input)?;
204  ws(input)?;
205  Ok(if carets.chars().count() >= 2 {
206    FloatKind::Double
207  } else {
208    FloatKind::Single
209  })
210}
211
212/// One top-level operation. Branch order mirrors `compile_replacement_tokens`'s
213/// `while`-loop priority. `pi`/`open` consume leading whitespace (Perl regexes
214/// allow `^\s*<`); `close`/`text` do not (asymmetry preserved faithfully).
215fn op(input: &mut &str) -> ModalResult<ReplacementOp> {
216  alt((
217    conditional_op,
218    pi_op,
219    open_tag_op,
220    close_tag_op,
221    absorb_value_op,
222    attribute_op,
223    text_op,
224  ))
225  .parse_next(input)
226}
227
228fn conditional_op(input: &mut &str) -> ModalResult<ReplacementOp> {
229  peek((literal("?"), one_of(['#', '&']))).parse_next(input)?;
230  let (test, if_s, else_s) = parse_conditional_raw(input)?;
231  let then_ops = reparse_ops(&if_s)?;
232  let else_ops = reparse_ops(&else_s)?;
233  Ok(ReplacementOp::Conditional { test, then_ops, else_ops })
234}
235
236fn pi_op(input: &mut &str) -> ModalResult<ReplacementOp> {
237  ws(input)?;
238  literal("<?").parse_next(input)?;
239  let name = cut_err_(qname, input)?;
240  let attrs = avpairs(input)?;
241  ws(input)?;
242  cut_err_lit("?>", input)?;
243  Ok(ReplacementOp::ProcessingInstruction { qname: name, attrs })
244}
245
246fn open_tag_op(input: &mut &str) -> ModalResult<ReplacementOp> {
247  ws(input)?;
248  literal("<").parse_next(input)?;
249  // Backtrackable: `</…` or `<?…` must fall through to other branches.
250  let name = qname.parse_next(input)?;
251  let attrs = avpairs(input)?;
252  let self_closing = opt(literal("/")).parse_next(input)?.is_some();
253  cut_err_lit(">", input)?;
254  Ok(ReplacementOp::OpenElement {
255    qname: name,
256    attrs,
257    float: None,
258    self_closing,
259  })
260}
261
262fn close_tag_op(input: &mut &str) -> ModalResult<ReplacementOp> {
263  // `</qname\s*>` — no leading whitespace (Perl `LEAD_CLOSE_TAG_RE`).
264  literal("</").parse_next(input)?;
265  let name = cut_err_(qname, input)?;
266  ws(input)?;
267  cut_err_lit(">", input)?;
268  Ok(ReplacementOp::CloseElement { qname: name })
269}
270
271fn absorb_value_op(input: &mut &str) -> ModalResult<ReplacementOp> {
272  // Gate on `#`/`&` (Perl `LEAD_VALUE_RE`); content_value has no literal fallback.
273  peek(one_of(['#', '&'])).parse_next(input)?;
274  let value = content_value(input)?;
275  Ok(ReplacementOp::AbsorbValue { value })
276}
277
278fn attribute_op(input: &mut &str) -> ModalResult<ReplacementOp> {
279  // `qname\s*=\s*'…'` (Perl `QNAME_KEY_RE`), no leading whitespace.
280  let key = qname.parse_next(input)?;
281  if opt((ws_p, literal("="), ws_p)).parse_next(input)?.is_none() {
282    // qname without `=` is not an attribute — let alt try `text`.
283    return Err(ErrMode::Backtrack(ContextError::new()));
284  }
285  let value = attr_string(input)?;
286  Ok(ReplacementOp::SetAttribute { key, value, float: false })
287}
288
289fn text_op(input: &mut &str) -> ModalResult<ReplacementOp> {
290  // Random text stops at `<` and the specials (Perl `LEAD_RANDOM_TEXT_RE`).
291  let raw = take_literal_run(input, "", true)?;
292  Ok(ReplacementOp::Text { text: unquote(&raw) })
293}
294
295// ── shared sub-parsers ──
296
297/// `?test(a)(b)` → `(test, a_raw, b_raw)`. The caller (op / avpair / string
298/// context) re-parses the raw branch strings appropriately. Mirrors Perl
299/// `parse_conditional`: a missing first paren ⇒ both branches empty; a missing
300/// second paren ⇒ else branch empty.
301fn parse_conditional_raw(input: &mut &str) -> ModalResult<(Value, String, String)> {
302  literal("?").parse_next(input)?;
303  let test = match content_value(input) {
304    Ok(v) => v,
305    Err(ErrMode::Backtrack(e)) => return Err(ErrMode::Cut(e)),
306    Err(e) => return Err(e),
307  };
308  match opt(bracketed).parse_next(input)? {
309    None => Ok((test, String::new(), String::new())),
310    Some(a) => {
311      let b = opt(bracketed).parse_next(input)?.unwrap_or_default();
312      Ok((test, a, b))
313    },
314  }
315}
316
317/// Extract a balanced `(…)` group, returning the inner text (Perl
318/// `extract_bracketed`). Leading whitespace is skipped; a non-whitespace char
319/// before any `(` means "no group here" (backtrack, input unchanged). Nested
320/// parens balance; quotes are *not* special (faithful to the original).
321fn bracketed(input: &mut &str) -> ModalResult<String> {
322  let s: &str = input;
323  let mut level: i32 = 0;
324  let mut has_open = false;
325  let mut extracted = String::new();
326  let mut consumed = 0usize;
327  let mut closed = false;
328  for (i, c) in s.char_indices() {
329    match c {
330      ')' => {
331        level -= 1;
332        if level < 1 {
333          consumed = i + c.len_utf8();
334          closed = true;
335          break;
336        }
337        extracted.push(c);
338      },
339      '(' => {
340        has_open = true;
341        level += 1;
342        if level > 1 {
343          extracted.push(c);
344        }
345      },
346      other => {
347        if level > 0 {
348          extracted.push(other);
349        } else if !other.is_whitespace() {
350          break; // non-ws before any '(' — not a group
351        }
352      },
353    }
354  }
355  if has_open && closed {
356    *input = &s[consumed..];
357    Ok(extracted)
358  } else {
359    Err(ErrMode::Backtrack(ContextError::new()))
360  }
361}
362
363/// A value with no literal fallback: `&func(…)`, `#n`, `#name` (Perl
364/// `translate_value` gated by `LEAD_VALUE_RE`).
365fn content_value(input: &mut &str) -> ModalResult<Value> {
366  alt((func_value, arg_value, prop_value)).parse_next(input)
367}
368
369/// A value *with* literal fallback (used for `&func` arguments): tries the value
370/// forms, else a literal run excluding `exclude`.
371fn full_value(input: &mut &str, exclude: &str) -> ModalResult<Value> {
372  if let Some(v) = opt(content_value).parse_next(input)? {
373    return Ok(v);
374  }
375  let raw = take_literal_run(input, exclude, false)?;
376  Ok(Value::Literal(unquote(&raw)))
377}
378
379fn arg_value(input: &mut &str) -> ModalResult<Value> {
380  literal("#").parse_next(input)?;
381  let digits = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
382  let n: usize = digits
383    .parse()
384    .map_err(|_| ErrMode::Backtrack(ContextError::new()))?;
385  if !(1..=9).contains(&n) {
386    return Err(ErrMode::Cut(ContextError::new()));
387  }
388  Ok(Value::Arg(n))
389}
390
391fn prop_value(input: &mut &str) -> ModalResult<Value> {
392  literal("#").parse_next(input)?;
393  let name = take_while(1.., |c: char| c.is_alphanumeric() || c == '_' || c == '-')
394    .map(str::to_string)
395    .parse_next(input)?;
396  Ok(Value::Prop(name))
397}
398
399fn func_value(input: &mut &str) -> ModalResult<Value> {
400  // `&([\w:]*)\(` (Perl `FN_RE`).
401  literal("&").parse_next(input)?;
402  let name = take_while(0.., |c: char| c.is_alphanumeric() || c == '_' || c == ':')
403    .map(str::to_string)
404    .parse_next(input)?;
405  literal("(").parse_next(input)?; // no '(' ⇒ backtrack (a bare `&amp;` is text)
406  let mut args = Vec::new();
407  loop {
408    if probe(|i| (ws_p, literal(")")).void().parse_next(i), input) {
409      break;
410    }
411    ws(input)?;
412    let arg = if probe(|i| one_of(['\'', '"']).parse_next(i), input) {
413      FuncArg::Str(attr_string(input)?)
414    } else {
415      FuncArg::Value(full_value(input, ",)")?)
416    };
417    args.push(arg);
418    if opt((ws_p, literal(","), ws_p)).parse_next(input)?.is_none() {
419      break;
420    }
421  }
422  ws(input)?;
423  cut_err_lit(")", input)?;
424  Ok(Value::Func { name, args })
425}
426
427/// A quoted, interpolated attribute string (Perl `translate_string`). If no
428/// opening quote is present, it consumes a single char and yields an empty value
429/// — faithfully reproducing the original's quirk.
430fn attr_string(input: &mut &str) -> ModalResult<AttrValue> {
431  ws(input)?;
432  let quote = match opt(one_of(['\'', '"'])).parse_next(input)? {
433    Some(q) => q,
434    None => {
435      if let Some(c) = input.chars().next() {
436        *input = &input[c.len_utf8()..];
437      }
438      return Ok(AttrValue { parts: Vec::new() });
439    },
440  };
441  let mut parts = Vec::new();
442  loop {
443    if input.is_empty() {
444      break;
445    }
446    if input.starts_with(quote) {
447      *input = &input[quote.len_utf8()..];
448      break;
449    }
450    if probe(
451      |i| (literal("?"), one_of(['#', '&'])).void().parse_next(i),
452      input,
453    ) {
454      let (test, if_s, else_s) = parse_conditional_raw(input)?;
455      let then_val = parse_single_value(&if_s)?;
456      let else_val = parse_single_value(&else_s)?;
457      parts.push(AttrPart::Conditional { test, then_val, else_val });
458      continue;
459    }
460    if probe(|i| one_of(['#', '&']).parse_next(i), input) {
461      parts.push(AttrPart::Value(content_value(input)?));
462      continue;
463    }
464    let raw = take_literal_run(input, "'\"", false)?;
465    parts.push(AttrPart::Literal(unquote(&raw)));
466  }
467  Ok(AttrValue { parts })
468}
469
470/// A set of attribute pairs (Perl `translate_avpairs`): conditionals and
471/// `key='v'` pairs, leading whitespace trimmed each iteration.
472fn avpairs(input: &mut &str) -> ModalResult<Vec<AttrPair>> {
473  let mut pairs = Vec::new();
474  loop {
475    ws(input)?;
476    if let Some(c) = opt(avpair_conditional).parse_next(input)? {
477      pairs.push(c);
478      continue;
479    }
480    if let Some(kv) = opt(avpair_keyval).parse_next(input)? {
481      pairs.push(kv);
482      continue;
483    }
484    break;
485  }
486  Ok(pairs)
487}
488
489fn avpair_conditional(input: &mut &str) -> ModalResult<AttrPair> {
490  peek((literal("?"), one_of(['#', '&']))).parse_next(input)?;
491  let (test, if_s, else_s) = parse_conditional_raw(input)?;
492  Ok(AttrPair::Conditional {
493    test,
494    then_attrs: reparse_avpairs(&if_s),
495    else_attrs: reparse_avpairs(&else_s),
496  })
497}
498
499fn avpair_keyval(input: &mut &str) -> ModalResult<AttrPair> {
500  let key = qname.parse_next(input)?;
501  if opt((ws_p, literal("="), ws_p)).parse_next(input)?.is_none() {
502    return Err(ErrMode::Backtrack(ContextError::new()));
503  }
504  let value = attr_string(input)?;
505  Ok(AttrPair::KeyValue { key, value })
506}
507
508/// A single value parsed from a conditional branch in string context (Perl
509/// `translate_value` on the branch). Empty branch ⇒ empty literal; trailing text
510/// is ignored (lenient, matching the original).
511fn parse_single_value(s: &str) -> ModalResult<Value> {
512  if s.is_empty() {
513    return Ok(Value::Literal(String::new()));
514  }
515  let mut inp: &str = s;
516  full_value(&mut inp, "")
517}
518
519/// Re-parse a conditional op-branch as a full op-list (strict: must consume all,
520/// mirroring `compile_replacement_tokens` looping to empty).
521fn reparse_ops(s: &str) -> ModalResult<Vec<ReplacementOp>> {
522  ops_with_float
523    .parse(s)
524    .map_err(|_| ErrMode::Cut(ContextError::new()))
525}
526
527/// Re-parse a conditional avpair-branch as an attribute list (lenient: trailing
528/// text ignored, like `translate_avpairs`).
529fn reparse_avpairs(s: &str) -> Vec<AttrPair> {
530  let mut inp: &str = s;
531  avpairs(&mut inp).unwrap_or_default()
532}
533
534/// `qname` (XML Name, Perl `QNAME_RE`). Approximated as alnum/`_`/`:`/`.`/`-`
535/// with an alpha/`_`/`:` start — covers every `ltx:`-namespaced name in practice.
536fn qname(input: &mut &str) -> ModalResult<String> {
537  (one_of(is_qname_start), take_while(0.., is_qname_continue))
538    .take()
539    .map(str::to_string)
540    .parse_next(input)
541}
542
543fn is_qname_start(c: char) -> bool { c.is_alphabetic() || c == '_' || c == ':' }
544fn is_qname_continue(c: char) -> bool { c.is_alphanumeric() || matches!(c, '_' | ':' | '.' | '-') }
545
546/// Consume optional whitespace, discarding it.
547fn ws(input: &mut &str) -> ModalResult<()> {
548  let _ = take_while(0.., |c: char| c.is_whitespace()).parse_next(input)?;
549  Ok(())
550}
551
552/// Whitespace as a tuple-usable parser (returns `()`).
553fn ws_p(input: &mut &str) -> ModalResult<()> { ws(input) }
554
555/// Non-consuming lookahead test: run `p` on a copy and report whether it
556/// succeeds, leaving `input` untouched. (Pins the winnow error type, which a bare
557/// `peek(...).is_ok()` cannot infer.)
558fn probe<O>(mut p: impl FnMut(&mut &str) -> ModalResult<O>, input: &str) -> bool {
559  let mut s: &str = input;
560  p(&mut s).is_ok()
561}
562
563/// A literal that hard-fails (`cut_err`) instead of backtracking.
564fn cut_err_lit(lit: &'static str, input: &mut &str) -> ModalResult<()> {
565  match literal(lit).parse_next(input) {
566    Ok(_) => Ok(()),
567    Err(ErrMode::Backtrack(e)) => Err(ErrMode::Cut(e)),
568    Err(e) => Err(e),
569  }
570}
571
572/// Run a parser, converting a `Backtrack` into a `Cut` (committed position).
573fn cut_err_<O>(mut p: impl FnMut(&mut &str) -> ModalResult<O>, input: &mut &str) -> ModalResult<O> {
574  match p(input) {
575    Ok(o) => Ok(o),
576    Err(ErrMode::Backtrack(e)) => Err(ErrMode::Cut(e)),
577    Err(e) => Err(e),
578  }
579}
580
581/// Maximal run of literal text. A "unit" is `&amp;`, a TeX control sequence
582/// `\letters`, an escape `\X`, or one ordinary char. Stops at `#`/`?`, a bare
583/// `&`, a lone `\`, the `exclude` chars, and (when `exclude_lt`) `<`. Returns the
584/// raw run (NOT yet `unquote`d). Faithful to Perl's `QUOTED_SPECIALS` classes.
585fn take_literal_run(input: &mut &str, exclude: &str, exclude_lt: bool) -> ModalResult<String> {
586  let s: &str = input;
587  let mut pos = 0usize;
588  while pos < s.len() {
589    let rest = &s[pos..];
590    let c = rest.chars().next().unwrap();
591    if c == '&' {
592      if rest.starts_with("&amp;") {
593        pos += 5;
594        continue;
595      }
596      break; // bare '&' is special
597    }
598    if c == '\\' {
599      let after = &rest[1..];
600      let letters: usize = after
601        .chars()
602        .take_while(|ch| ch.is_ascii_alphabetic() || *ch == '@')
603        .map(char::len_utf8)
604        .sum();
605      if letters > 0 {
606        pos += 1 + letters; // \textbf etc.
607        continue;
608      }
609      if let Some(nc) = after.chars().next() {
610        pos += 1 + nc.len_utf8(); // \X escape
611        continue;
612      }
613      break; // lone trailing backslash
614    }
615    if c == '#' || c == '?' {
616      break;
617    }
618    if exclude_lt && c == '<' {
619      break;
620    }
621    if exclude.contains(c) {
622      break;
623    }
624    pos += c.len_utf8();
625  }
626  if pos == 0 {
627    return Err(ErrMode::Backtrack(ContextError::new()));
628  }
629  let raw = s[..pos].to_string();
630  *input = &s[pos..];
631  Ok(raw)
632}
633
634/// Reverse the template's escape conventions (Perl `unquote`). Reproduces the
635/// original's exact behavior, including the quirk that `\X` (X ∈ `#?(&,<>\%`) is
636/// **removed entirely** (the original `ESCAPED_OP` regex has no capture group, so
637/// the replacement is the empty string), then `##`→`#` and `&amp;`→`&`.
638pub fn unquote(text: &str) -> String {
639  const ESCAPED: &[char] = &['#', '?', '(', '&', ',', '<', '>', '\\', '%'];
640  let mut out = String::with_capacity(text.len());
641  let mut i = 0usize;
642  while i < text.len() {
643    let rest = &text[i..];
644    let c = rest.chars().next().unwrap();
645    if c == '\\'
646      && let Some(nc) = rest[1..].chars().next()
647      && ESCAPED.contains(&nc)
648    {
649      i += 1 + nc.len_utf8(); // drop the whole `\X`
650      continue;
651    }
652    out.push(c);
653    i += c.len_utf8();
654  }
655  out.replace("##", "#").replace("&amp;", "&")
656}
657
658/// Double every backslash so the text can be embedded as a Rust string literal
659/// (Perl `slashify`). Used by the compile-time codegen consumer.
660pub fn slashify(text: &str) -> String { text.replace('\\', "\\\\") }
661
662// ─────────────────────── runtime interpreter ───────────────────────
663
664/// Execute a parsed replacement against a live `Document` — the runtime consumer
665/// of the AST. Mirrors the Document operations the compile-time codegen emits.
666pub fn apply_ops(
667  ops: &[ReplacementOp],
668  document: &mut Document,
669  args: &[Option<Digested>],
670  props: &SymHashMap<Stored>,
671) -> Result<()> {
672  let mut savenode: Option<Node> = None;
673  exec_ops(ops, document, args, props, &mut savenode)?;
674  if let Some(sn) = savenode {
675    document.set_node(&sn);
676  }
677  Ok(())
678}
679
680fn exec_ops(
681  ops: &[ReplacementOp],
682  document: &mut Document,
683  args: &[Option<Digested>],
684  props: &SymHashMap<Stored>,
685  savenode: &mut Option<Node>,
686) -> Result<()> {
687  for op in ops {
688    match op {
689      ReplacementOp::OpenElement {
690        qname,
691        attrs,
692        float,
693        self_closing,
694      } => {
695        if let Some(fk) = float {
696          *savenode = document.float_to_element(qname, matches!(fk, FloatKind::Double))?;
697        }
698        let av = eval_avpairs(attrs, args, props)?;
699        if av.is_empty() {
700          document.open_element(qname, None, None)?;
701        } else {
702          let mut map: HashMap<String, String> = HashMap::default();
703          for (k, v) in av {
704            map.insert(k, v);
705          }
706          let this_font_opt: Option<Cow<Font>> = match props.get("font") {
707            Some(Stored::Font(f)) => Some(Cow::Borrowed(&**f)),
708            Some(Stored::FontDirective(FontDirective::Asset(fa))) => Some(Cow::Borrowed(&**fa)),
709            Some(Stored::FontDirective(FontDirective::Closure(code))) => {
710              Some(Cow::Owned(code(None)?))
711            },
712            _ => None,
713          };
714          if let Some(this_font) = this_font_opt {
715            document.open_element(qname, Some(map), Some(&this_font))?;
716          } else {
717            document.open_element(qname, Some(map), None)?;
718          }
719        }
720        if *self_closing {
721          document.close_element(qname)?;
722        }
723      },
724      ReplacementOp::CloseElement { qname } => {
725        document.close_element(qname)?;
726      },
727      ReplacementOp::ProcessingInstruction { qname, attrs } => {
728        let av = eval_avpairs(attrs, args, props)?;
729        if av.is_empty() {
730          document.insert_pi(qname, None)?;
731        } else {
732          let mut map: HashMap<String, String> = HashMap::default();
733          for (k, v) in av {
734            map.insert(k, v);
735          }
736          document.insert_pi(qname, Some(map))?;
737        }
738      },
739      ReplacementOp::AbsorbValue { value } => {
740        absorb_value(value, document, args, props)?;
741      },
742      ReplacementOp::SetAttribute { key, value, float } => {
743        let val_str = eval_attr_value(value, args, props)?;
744        if *float {
745          *savenode = document.float_to_attribute(key);
746          let mut node = document.get_node().clone();
747          document.set_attribute(&mut node, key, &val_str)?;
748          if let &mut Some(ref sn) = savenode {
749            document.set_node(sn);
750          }
751        } else {
752          let mut node = document.get_node().clone();
753          document.set_attribute(&mut node, key, &val_str)?;
754        }
755      },
756      ReplacementOp::Text { text } => {
757        document.absorb_string(text, props)?;
758      },
759      ReplacementOp::Conditional { test, then_ops, else_ops } => {
760        if eval_bool(test, args, props)? {
761          exec_ops(then_ops, document, args, props, savenode)?;
762        } else {
763          exec_ops(else_ops, document, args, props, savenode)?;
764        }
765      },
766    }
767  }
768  Ok(())
769}
770
771/// Evaluate attribute pairs to `(key, value)` strings (font key dropped, as the
772/// codegen drops it — the open font comes from `props["font"]` instead).
773fn eval_avpairs(
774  attrs: &[AttrPair],
775  args: &[Option<Digested>],
776  props: &SymHashMap<Stored>,
777) -> Result<Vec<(String, String)>> {
778  let mut out = Vec::new();
779  for a in attrs {
780    match a {
781      AttrPair::KeyValue { key, value } => {
782        if key == "font" {
783          continue;
784        }
785        out.push((key.clone(), eval_attr_value(value, args, props)?));
786      },
787      AttrPair::Conditional { test, then_attrs, else_attrs } => {
788        let branch = if eval_bool(test, args, props)? {
789          then_attrs
790        } else {
791          else_attrs
792        };
793        out.extend(eval_avpairs(branch, args, props)?);
794      },
795    }
796  }
797  Ok(out)
798}
799
800fn eval_attr_value(
801  v: &AttrValue,
802  args: &[Option<Digested>],
803  props: &SymHashMap<Stored>,
804) -> Result<String> {
805  let mut s = String::new();
806  for part in &v.parts {
807    match part {
808      AttrPart::Literal(lit) => s.push_str(lit),
809      AttrPart::Value(val) => s.push_str(&value_to_attribute(val, args, props)?),
810      AttrPart::Conditional { test, then_val, else_val } => {
811        let chosen = if eval_bool(test, args, props)? {
812          then_val
813        } else {
814          else_val
815        };
816        s.push_str(&value_to_attribute(chosen, args, props)?);
817      },
818    }
819  }
820  Ok(s)
821}
822
823/// Render a value as an attribute string: `to_attribute()` of the resolved
824/// argument/property, or empty when absent (codegen's
825/// `match … { Some(v) => v.to_attribute(), None => String::new() }`).
826fn value_to_attribute(
827  v: &Value,
828  args: &[Option<Digested>],
829  props: &SymHashMap<Stored>,
830) -> Result<String> {
831  Ok(match v {
832    Value::Arg(n) => match args.get(n - 1) {
833      Some(Some(d)) => d.to_attribute(),
834      _ => String::new(),
835    },
836    Value::Prop(name) => match props.get(name) {
837      Some(stored) => stored.to_attribute(),
838      None => String::new(),
839    },
840    Value::Func { name, args: fargs } => call_func(name, fargs, args, props)?,
841    Value::Literal(lit) => lit.clone(),
842  })
843}
844
845/// Absorb a value at content position (codegen's `Into<Option<Digested>>` +
846/// `document.absorb`).
847fn absorb_value(
848  v: &Value,
849  document: &mut Document,
850  args: &[Option<Digested>],
851  props: &SymHashMap<Stored>,
852) -> Result<()> {
853  match v {
854    Value::Arg(n) => {
855      if let Some(Some(d)) = args.get(n - 1) {
856        document.absorb(d, None)?;
857      }
858    },
859    Value::Prop(name) => {
860      if let Some(stored) = props.get(name) {
861        let dig: Option<Digested> = stored.into();
862        if let Some(ref d) = dig {
863          document.absorb(d, None)?;
864        }
865      }
866    },
867    Value::Func { name, args: fargs } => {
868      let s = call_func(name, fargs, args, props)?;
869      if !s.is_empty() {
870        document.absorb_string(&s, props)?;
871      }
872    },
873    Value::Literal(_) => {}, // a literal never reaches content position
874  }
875  Ok(())
876}
877
878/// The truth test of a conditional (codegen's
879/// `!v.to_string().is_empty() && v.to_string() != "false"`).
880fn eval_bool(v: &Value, args: &[Option<Digested>], props: &SymHashMap<Stored>) -> Result<bool> {
881  Ok(match v {
882    Value::Arg(n) => match args.get(n - 1) {
883      Some(Some(d)) => is_truthy(&d.to_string()),
884      _ => false,
885    },
886    Value::Prop(name) => match props.get(name) {
887      Some(stored) => is_truthy(&stored.to_string()),
888      None => false,
889    },
890    Value::Func { name, args: fargs } => is_truthy(&call_func(name, fargs, args, props)?),
891    Value::Literal(s) => is_truthy(s),
892  })
893}
894
895fn is_truthy(s: &str) -> bool { !s.is_empty() && s != "false" }
896
897/// Resolve a `&func(…)` at runtime through a curated whitelist (the compile-time
898/// path resolves these to Rust calls; the runtime path keeps untrusted scripts
899/// safe by only allowing vetted helpers). Arguments are rendered to strings.
900/// No active binding uses `&func` (they appear only in commented Perl-only
901/// templates), so the whitelist is intentionally minimal and grows on demand.
902fn call_func(
903  name: &str,
904  fargs: &[FuncArg],
905  args: &[Option<Digested>],
906  props: &SymHashMap<Stored>,
907) -> Result<String> {
908  let mut argv: Vec<String> = Vec::with_capacity(fargs.len());
909  for fa in fargs {
910    argv.push(match fa {
911      FuncArg::Value(v) => value_to_attribute(v, args, props)?,
912      FuncArg::Str(s) => eval_attr_value(s, args, props)?,
913    });
914  }
915  match name {
916    "ToString" => Ok(argv.join("")),
917    // `&GetKeyVal(#1, key)` — extract a value from a keyval argument rendered to
918    // its `k=v,…` source form. Mirrors the compile-time prelude `GetKeyVal`
919    // (which reads the digested KeyVals directly) for the scalar values these
920    // templates use; missing key → "".
921    "GetKeyVal" => {
922      let kv = argv.first().map(String::as_str).unwrap_or("");
923      let key = argv.get(1).map(String::as_str).unwrap_or("");
924      Ok(
925        crate::keyval::split_keyval_source(kv)
926          .into_iter()
927          .find(|(k, _)| k == key)
928          .map(|(_, v)| v)
929          .unwrap_or_default(),
930      )
931    },
932    _ => Err(Error::from(format!(
933      "runtime template: function &{name}(…) is not in the whitelist"
934    ))),
935  }
936}
937
938// ────────────────────────────── tests ──────────────────────────────
939
940#[cfg(test)]
941mod tests {
942  use super::*;
943
944  fn lit(s: &str) -> AttrValue {
945    AttrValue {
946      parts: vec![AttrPart::Literal(s.to_string())],
947    }
948  }
949  fn argval(n: usize) -> AttrValue {
950    AttrValue {
951      parts: vec![AttrPart::Value(Value::Arg(n))],
952    }
953  }
954
955  #[test]
956  fn plain_element_with_arg() {
957    let ops = parse_replacement("<ltx:emph>#1</ltx:emph>").unwrap();
958    assert_eq!(ops, vec![
959      ReplacementOp::OpenElement {
960        qname:        "ltx:emph".into(),
961        attrs:        vec![],
962        float:        None,
963        self_closing: false,
964      },
965      ReplacementOp::AbsorbValue { value: Value::Arg(1) },
966      ReplacementOp::CloseElement { qname: "ltx:emph".into() },
967    ]);
968  }
969
970  #[test]
971  fn element_with_literal_attribute_and_arg() {
972    let ops = parse_replacement("<ltx:text class='ok'>#1</ltx:text>").unwrap();
973    assert_eq!(ops, vec![
974      ReplacementOp::OpenElement {
975        qname:        "ltx:text".into(),
976        attrs:        vec![AttrPair::KeyValue {
977          key:   "class".into(),
978          value: lit("ok"),
979        }],
980        float:        None,
981        self_closing: false,
982      },
983      ReplacementOp::AbsorbValue { value: Value::Arg(1) },
984      ReplacementOp::CloseElement { qname: "ltx:text".into() },
985    ]);
986  }
987
988  #[test]
989  fn attribute_value_interpolates_arg() {
990    let ops = parse_replacement("<ltx:ref class='#2'>#1</ltx:ref>").unwrap();
991    let ReplacementOp::OpenElement { attrs, .. } = &ops[0] else {
992      panic!()
993    };
994    assert_eq!(attrs, &vec![AttrPair::KeyValue {
995      key:   "class".into(),
996      value: argval(2),
997    }]);
998  }
999
1000  #[test]
1001  fn self_closing_element() {
1002    let ops = parse_replacement("<ltx:break/>").unwrap();
1003    assert_eq!(ops, vec![ReplacementOp::OpenElement {
1004      qname:        "ltx:break".into(),
1005      attrs:        vec![],
1006      float:        None,
1007      self_closing: true,
1008    }]);
1009  }
1010
1011  #[test]
1012  fn whitespace_before_tag_is_dropped_but_text_kept() {
1013    // Leading ws before an open tag is eaten by the tag (Perl `^\s*<`); a close
1014    // tag has no leading-ws rule so the ws becomes text.
1015    let ops = parse_replacement("<a>\n  <b></b>\n  </a>").unwrap();
1016    assert_eq!(ops, vec![
1017      ReplacementOp::OpenElement {
1018        qname:        "a".into(),
1019        attrs:        vec![],
1020        float:        None,
1021        self_closing: false,
1022      },
1023      ReplacementOp::OpenElement {
1024        qname:        "b".into(),
1025        attrs:        vec![],
1026        float:        None,
1027        self_closing: false,
1028      },
1029      ReplacementOp::CloseElement { qname: "b".into() },
1030      ReplacementOp::Text { text: "\n  ".into() },
1031      ReplacementOp::CloseElement { qname: "a".into() },
1032    ]);
1033  }
1034
1035  #[test]
1036  fn footnote_corpus_specimen() {
1037    // plain_constructs.rs:293 — the richest active template.
1038    let ops = parse_replacement(
1039      "^<ltx:note role='footnote' ?#mark(mark='#mark')()>?#prenote(#prenote )()#2</ltx:note>",
1040    )
1041    .unwrap();
1042    assert_eq!(ops, vec![
1043      ReplacementOp::OpenElement {
1044        qname:        "ltx:note".into(),
1045        attrs:        vec![
1046          AttrPair::KeyValue {
1047            key:   "role".into(),
1048            value: lit("footnote"),
1049          },
1050          AttrPair::Conditional {
1051            test:       Value::Prop("mark".into()),
1052            then_attrs: vec![AttrPair::KeyValue {
1053              key:   "mark".into(),
1054              value: AttrValue {
1055                parts: vec![AttrPart::Value(Value::Prop("mark".into()))],
1056              },
1057            }],
1058            else_attrs: vec![],
1059          },
1060        ],
1061        float:        Some(FloatKind::Single),
1062        self_closing: false,
1063      },
1064      ReplacementOp::Conditional {
1065        test:     Value::Prop("prenote".into()),
1066        then_ops: vec![
1067          ReplacementOp::AbsorbValue {
1068            value: Value::Prop("prenote".into()),
1069          },
1070          ReplacementOp::Text { text: " ".into() },
1071        ],
1072        else_ops: vec![],
1073      },
1074      ReplacementOp::AbsorbValue { value: Value::Arg(2) },
1075      ReplacementOp::CloseElement { qname: "ltx:note".into() },
1076    ]);
1077  }
1078
1079  #[test]
1080  fn pi_corpus_specimen() {
1081    // latex_constructs.rs:2702 / :4088 — PI with inline conditional.
1082    let ops = parse_replacement("<?latexml class='#2' ?#1(options='#1')?>").unwrap();
1083    assert_eq!(ops, vec![ReplacementOp::ProcessingInstruction {
1084      qname: "latexml".into(),
1085      attrs: vec![
1086        AttrPair::KeyValue {
1087          key:   "class".into(),
1088          value: argval(2),
1089        },
1090        AttrPair::Conditional {
1091          test:       Value::Arg(1),
1092          then_attrs: vec![AttrPair::KeyValue {
1093            key:   "options".into(),
1094            value: argval(1),
1095          }],
1096          else_attrs: vec![],
1097        },
1098      ],
1099    }]);
1100  }
1101
1102  #[test]
1103  fn float_double_caret() {
1104    let ops = parse_replacement("^^<ltx:x/>").unwrap();
1105    let ReplacementOp::OpenElement { float, .. } = &ops[0] else {
1106      panic!()
1107    };
1108    assert_eq!(float, &Some(FloatKind::Double));
1109  }
1110
1111  #[test]
1112  fn top_level_conditional_with_else() {
1113    let ops = parse_replacement("?#1(<a/>)(<b/>)").unwrap();
1114    assert_eq!(ops, vec![ReplacementOp::Conditional {
1115      test:     Value::Arg(1),
1116      then_ops: vec![ReplacementOp::OpenElement {
1117        qname:        "a".into(),
1118        attrs:        vec![],
1119        float:        None,
1120        self_closing: true,
1121      }],
1122      else_ops: vec![ReplacementOp::OpenElement {
1123        qname:        "b".into(),
1124        attrs:        vec![],
1125        float:        None,
1126        self_closing: true,
1127      }],
1128    }]);
1129  }
1130
1131  #[test]
1132  fn prop_hole_at_content_and_arg_distinguished() {
1133    let ops = parse_replacement("#mark#2").unwrap();
1134    assert_eq!(ops, vec![
1135      ReplacementOp::AbsorbValue {
1136        value: Value::Prop("mark".into()),
1137      },
1138      ReplacementOp::AbsorbValue { value: Value::Arg(2) },
1139    ]);
1140  }
1141
1142  #[test]
1143  fn func_value_parses() {
1144    let ops = parse_replacement("<a x='&ToString(#1)'/>").unwrap();
1145    let ReplacementOp::OpenElement { attrs, .. } = &ops[0] else {
1146      panic!()
1147    };
1148    assert_eq!(attrs, &vec![AttrPair::KeyValue {
1149      key:   "x".into(),
1150      value: AttrValue {
1151        parts: vec![AttrPart::Value(Value::Func {
1152          name: "ToString".into(),
1153          args: vec![FuncArg::Value(Value::Arg(1))],
1154        })],
1155      },
1156    }]);
1157  }
1158
1159  #[test]
1160  fn unquote_reproduces_original_quirks() {
1161    assert_eq!(unquote("a&amp;b"), "a&b");
1162    assert_eq!(unquote(r"\#"), ""); // \X (X special) removed entirely
1163    assert_eq!(unquote(r"\textbf"), r"\textbf"); // CS survives
1164    assert_eq!(unquote("a##b"), "a#b");
1165  }
1166
1167  #[test]
1168  fn empty_template_is_empty_oplist() {
1169    assert_eq!(parse_replacement("").unwrap(), vec![]);
1170  }
1171
1172  // ── evaluation-semantics conformance (Document-free) ──
1173  //
1174  // These exercise the runtime interpreter's value/attribute/condition
1175  // evaluation directly, with controlled args/props, and assert it computes
1176  // exactly what the compile-time codegen emits. In particular they pin the
1177  // crucial point that attribute values render via `to_attribute()` (matching
1178  // codegen) — NOT the `untex()` the previous byte-scanner used.
1179
1180  use crate::common::arena;
1181
1182  fn dig(s: &str) -> Digested { s.to_string().into() }
1183
1184  fn props_with(pairs: &[(&str, &str)]) -> SymHashMap<Stored> {
1185    let mut m = SymHashMap::default();
1186    for (k, v) in pairs {
1187      m.insert(k, Stored::String(arena::pin(v)));
1188    }
1189    m
1190  }
1191
1192  /// Pull the `attrs` out of the footnote specimen's `OpenElement`.
1193  fn footnote_attrs() -> Vec<AttrPair> {
1194    let ops = parse_replacement(
1195      "^<ltx:note role='footnote' ?#mark(mark='#mark')()>?#prenote(#prenote )()#2</ltx:note>",
1196    )
1197    .unwrap();
1198    match &ops[0] {
1199      ReplacementOp::OpenElement { attrs, .. } => attrs.clone(),
1200      _ => panic!("expected OpenElement"),
1201    }
1202  }
1203
1204  #[test]
1205  fn footnote_conditional_attr_fires_when_prop_present() {
1206    let attrs = footnote_attrs();
1207    let props = props_with(&[("mark", "MK")]);
1208    let av = eval_avpairs(&attrs, &[], &props).unwrap();
1209    // role literal + conditional mark attr (mark prop present ⇒ branch taken),
1210    // and the mark value is rendered via the Stored's `to_attribute()`.
1211    let mk = Stored::String(arena::pin("MK")).to_attribute();
1212    assert_eq!(av, vec![
1213      ("role".to_string(), "footnote".to_string()),
1214      ("mark".to_string(), mk)
1215    ]);
1216  }
1217
1218  #[test]
1219  fn footnote_conditional_attr_absent_when_prop_missing() {
1220    let attrs = footnote_attrs();
1221    let av = eval_avpairs(&attrs, &[], &SymHashMap::default()).unwrap();
1222    assert_eq!(av, vec![("role".to_string(), "footnote".to_string())]);
1223  }
1224
1225  #[test]
1226  fn footnote_prenote_condition_truth_test() {
1227    // `?#prenote(...)` truth test mirrors codegen: present non-"false" ⇒ true.
1228    assert!(
1229      eval_bool(
1230        &Value::Prop("prenote".into()),
1231        &[],
1232        &props_with(&[("prenote", "P")])
1233      )
1234      .unwrap()
1235    );
1236    assert!(!eval_bool(&Value::Prop("prenote".into()), &[], &SymHashMap::default()).unwrap());
1237    assert!(
1238      !eval_bool(
1239        &Value::Prop("x".into()),
1240        &[],
1241        &props_with(&[("x", "false")])
1242      )
1243      .unwrap()
1244    );
1245    assert!(!eval_bool(&Value::Prop("x".into()), &[], &props_with(&[("x", "")])).unwrap());
1246  }
1247
1248  #[test]
1249  fn pi_attr_interpolation_uses_to_attribute_and_conditional() {
1250    let ops = parse_replacement("<?latexml class='#2' ?#1(options='#1')?>").unwrap();
1251    let ReplacementOp::ProcessingInstruction { attrs, .. } = &ops[0] else {
1252      panic!()
1253    };
1254
1255    let a1 = dig("opts");
1256    let a2 = dig("article");
1257    let args = vec![Some(a1.clone()), Some(a2.clone())];
1258    let av = eval_avpairs(attrs, &args, &SymHashMap::default()).unwrap();
1259    // Both `#1` and `#2` render via Digested::to_attribute (the codegen rule),
1260    // and the `?#1(...)` conditional fires because arg 1 is present.
1261    assert_eq!(av, vec![
1262      ("class".to_string(), a2.to_attribute()),
1263      ("options".to_string(), a1.to_attribute()),
1264    ]);
1265
1266    // arg 1 absent ⇒ the conditional avpair drops out; class (arg 2) stays.
1267    let av2 = eval_avpairs(attrs, &[None, Some(a2.clone())], &SymHashMap::default()).unwrap();
1268    assert_eq!(av2, vec![("class".to_string(), a2.to_attribute())]);
1269  }
1270
1271  #[test]
1272  fn font_attribute_key_is_dropped() {
1273    // Codegen drops a literal `font=` attribute (the open font comes from
1274    // props["font"] instead). The interpreter must too.
1275    let ops = parse_replacement("<ltx:x font='ignored' class='keep'/>").unwrap();
1276    let ReplacementOp::OpenElement { attrs, .. } = &ops[0] else {
1277      panic!()
1278    };
1279    let av = eval_avpairs(attrs, &[], &SymHashMap::default()).unwrap();
1280    assert_eq!(av, vec![("class".to_string(), "keep".to_string())]);
1281  }
1282
1283  #[test]
1284  fn plain_text_only() {
1285    assert_eq!(parse_replacement("hello world").unwrap(), vec![
1286      ReplacementOp::Text { text: "hello world".into() }
1287    ]);
1288  }
1289}