Skip to main content

latexml_core/definition/
expandable.rs

1use std::borrow::Cow;
2
3// use std::fmt;
4use libxml::tree::Node;
5
6use crate::{
7  Digested,
8  common::{error::*, locator::Locator, object::Object},
9  definition::{BeforeDigestClosure, Definition, DigestionClosure, ExpansionBody},
10  state::*,
11};
12
13/// Returns true when `\protect` currently has no meaning, or is
14/// `\let`-equivalent to `\relax`. Used by the recursion guard in
15/// `Expandable::invoke` to distinguish `\def\foo{\protect\foo}`
16/// definitions under safe (`\@unexpandable@protect`/`\string`/…)
17/// vs. unsafe (`\relax`/undefined) `\protect` regimes. Only the
18/// unsafe regime actually runaways at full expansion.
19pub(crate) fn protect_is_relax_or_undefined() -> bool {
20  let protect = T_CS!("\\protect");
21  match lookup_meaning(&protect) {
22    None => true,
23    // Meaning equal to \relax's meaning ⇒ unsafe.
24    Some(stored) => match lookup_meaning(&T_CS!("\\relax")) {
25      None => false,
26      Some(relax) => stored == relax,
27    },
28  }
29}
30use crate::{
31  document::Document,
32  parameter::Parameters,
33  token::*,
34  tokens::{NO_TOKENS, Tokens},
35  whatsit::Whatsit,
36};
37
38#[derive(Debug, Clone, Default)]
39pub struct ExpandableOptions {
40  pub locked:            bool,
41  pub protected:         bool,
42  pub outer:             bool,
43  pub long:              bool,
44  pub scope:             Option<Scope>,
45  pub alias:             Option<String>,
46  pub mathactive:        bool,
47  pub robust:            bool,
48  pub nopack_parameters: bool,
49}
50
51#[derive(Debug, Clone)]
52pub struct Expandable {
53  pub is_protected: bool,
54  pub is_long:      bool,
55  pub is_outer:     bool,
56  pub has_cc_arg:   bool,
57  pub alias:        Option<String>,
58  pub locator:      Locator,
59  pub cs:           Token,
60  pub paramlist:    Option<Parameters>,
61  pub expansion:    Option<ExpansionBody>,
62}
63impl Default for Expandable {
64  fn default() -> Self {
65    Expandable {
66      is_protected: false,
67      is_long:      false,
68      is_outer:     false,
69      has_cc_arg:   false,
70      alias:        None,
71      locator:      Locator::default(),
72      cs:           T_CS!("Expandable"),
73      paramlist:    None,
74      expansion:    None,
75    }
76  }
77}
78impl PartialEq for Expandable {
79  fn eq(&self, other: &Expandable) -> bool {
80    self.paramlist == other.paramlist && self.expansion == other.expansion
81  }
82}
83
84// impl fmt::Display for Expandable {
85//   fn fmt(&self, _f: &mut fmt::Formatter) -> fmt::Result {
86//     todo!();
87//   }
88// }
89impl Object for Expandable {
90  fn is_definition(&self) -> bool { true }
91  fn is_expandable(&self) -> bool { true }
92  fn get_locator(&self) -> Option<Locator> { Some(self.locator) }
93  fn stringify(&self) -> String { <Self as Definition>::stringify_type(self, "Expandable") }
94}
95impl Definition for Expandable {
96  fn is_protected(&self) -> bool { self.is_protected }
97  fn get_parameters(&self) -> Option<&Parameters> { self.paramlist.as_ref() }
98  fn get_num_args(&self) -> usize {
99    match self.paramlist {
100      Some(ref params) => params.get_num_args(),
101      None => 0,
102    }
103  }
104  fn get_cs(&self) -> Cow<'_, Token> { Cow::Borrowed(&self.cs) }
105  fn get_cs_name(&self) -> Cow<'_, str> {
106    match self.alias {
107      Some(ref alias) => Cow::Borrowed(alias),
108      None => Cow::Owned(self.cs.with_cs_name(ToString::to_string)),
109    }
110  }
111  // fn with_cs_name<R, FnR>(&self, caller: FnR) -> R
112  // where FnR: FnOnce(&str) -> R {
113  //   match self.alias {
114  //     Some(ref alias) => caller(alias),
115  //     None => self.cs.with_cs_name(caller),
116  //   }
117  // }
118  fn get_expansion(&self) -> Option<&ExpansionBody> { self.expansion.as_ref() }
119  fn get_alias(&self) -> Option<&String> { self.alias.as_ref() }
120
121  /// Expand the expandable control sequence. This should be carried out by the Gullet.
122  fn invoke(&self, once_only: bool) -> Result<Tokens> {
123    // Perl shortcut for "trivial" macros that were tracing- or
124    // profiling-aware. Neither tracing nor profiling is implemented
125    // in the Rust port (the returned `_tracing` / `_profiled` values
126    // were discarded), so the two state lookups were pure overhead
127    // on every macro expansion (\~350k calls in si.tex alone per
128    // callgrind). Removed — re-introduce only alongside the actual
129    // tracing/profiling features if/when they land.
130    match &self.expansion {
131      Some(ExpansionBody::Closure(closure)) => {
132        // Harder to emulate \tracingmacros here.
133        let args = if let Some(ref parms) = self.paramlist {
134          parms.read_arguments(Some(self))?
135        } else {
136          Vec::new()
137        };
138        // Profiling: not implemented (Perl: startProfiling($profiled, 'expand'))
139        let result = closure(args)?;
140        // Tracing: Perl prints tracingCSName ==> tracetoString(result)
141        // Not implemented — silently skip to avoid panic on \tracingmacros=1
142        Ok(result)
143      },
144      Some(ExpansionBody::Tokens(tokens)) => {
145        let result = if self.paramlist.is_none() {
146          // Case: Trivial macro
147          // Profiling: not implemented (Perl: startProfiling($profiled, 'expand'))
148          // Tracing: Perl prints tracingCSName -> tracetoString(expansion)
149          // Not implemented — silently skip to avoid panic on \tracingmacros=1
150          // For trivial expansion, make sure we don't get \cs or
151          // \relax\cs direct recursion!  Perl: Expandable.pm L81-89.
152          //   if (!$onceonly && $$self{cs}) {
153          //     my ($t0, $t1) = ($$expansion[0], $$expansion[1]);
154          //     if ($t0 && ($t0->equals($$self{cs})
155          //         || ($t1 && $t1->equals($$self{cs})
156          //              && $t0->equals(T_CS('\protect'))))) {
157          //       Error('recursion', $$self{cs}, …,
158          //         "Token X expands into itself!", "defining as empty");
159          //       $expansion = TokensI(); } }
160          //
161          // Detect `\def\foo{\foo}` and `\def\foo{\protect\foo}`. Both
162          // are runaway-expansion landmines under any full-expansion
163          // context (`\edef`, `\xdef`, `\write`, `\message`). Perl
164          // reports an `Error:recursion` and substitutes an empty
165          // expansion for this invocation; the stored definition is
166          // unchanged (subsequent invocations re-detect and re-error).
167          //
168          // A previous Rust port tried to re-install the CS as
169          // `Stored::Token(self.cs)` to preserve `\ifx` identity for
170          // expl3 quarks (`\q_no_value`, `\q_nil`, …) and PGF keys
171          // (`\pgfkeys@mainstop`). That was a no-op: `assign_meaning`'s
172          // `token == mt` short-circuit (state.rs:1918-1922) rejects
173          // the `\foo → \foo` self-let, so the Expandable definition
174          // stayed in place and the recursion guard re-fired forever.
175          // Witness: cleveref × algorithmicx × hyperref on 2403.15855,
176          // where `\xdef\cref@currentprefix{\cref@currentprefix}` hung
177          // at the 60 s wall-clock guard.
178          //
179          // Identity for expl3 quarks is independent of this path: the
180          // quarks are defined `\cs_new_protected:Npn`, so they are
181          // protected expandables. Under partial expansion (the normal
182          // path) protected expandables aren't expanded at all — the
183          // recursion guard never fires, and the stored body keeps the
184          // CS as its first token, so `\ifx`-by-meaning comparisons
185          // remain distinct. Under full expansion the Error+empty
186          // recovery matches Perl exactly.
187          let is_recursion = if !once_only {
188            let token_vec = tokens.unlist_ref();
189            let t0_opt = token_vec.first();
190            let t1_opt = token_vec.get(1);
191            if let Some(t0) = t0_opt {
192              if t0 == &self.cs {
193                true
194              } else if let Some(t1) = t1_opt {
195                // `\protect\foo` is only an actual runaway when
196                // `\protect` currently expands to `\relax` (or is
197                // undefined). Under `\protected@edef` it is `\let`
198                // to `\@unexpandable@protect`, which turns the body
199                // into `\noexpand\protect\noexpand\foo` — both tokens
200                // become un-expandable and the loop terminates after
201                // one expansion. msg.sty (loaded transitively from
202                // french.sty, czech.sty, … under INCLUDE_STYLES=true)
203                // uses exactly this idiom for `\msgheader`, so the
204                // earlier blanket `\protect\foo`-is-runaway check
205                // fired ~3 errors per language-style paper. Witness:
206                // math9903002, gr-qc9511021, alg-geom9611022,
207                // math9807030/.../math9810088 (8 papers).
208                t1 == &self.cs && t0 == &T_CS!("\\protect") && protect_is_relax_or_undefined()
209              } else {
210                false
211              }
212            } else {
213              false
214            }
215          } else {
216            false
217          };
218          if is_recursion {
219            Error!(
220              "recursion",
221              &self.cs.to_string(),
222              s!("Token {} expands into itself!", self.cs)
223            );
224            Tokens!()
225          } else {
226            tokens.clone()
227          }
228        } else {
229          let args = if let Some(ref parms) = self.paramlist {
230            parms.read_arguments(Some(self))?
231          } else {
232            Vec::new()
233          };
234          if self.has_cc_arg {
235            // Do we actually need to substitute the args in?
236            // Pre-size: one entry per argument; avoids Vec doublings on
237            // macros with many args.
238            let mut args_tks = Vec::with_capacity(args.len());
239            for arg in args.iter() {
240              args_tks.push(arg.as_tokens()?);
241            }
242            tokens.substitute_parameters(args_tks.as_slice())
243          } else {
244            tokens.clone()
245          }
246        };
247        // Profiling: Perl appends T_MARKER(profiled) for exclusive profiling
248        // Not implemented — silently skip
249        Ok(result)
250      },
251      None => {
252        // we always need to read the arguments, for e.g. things like \@gobble
253        if let Some(ref parms) = self.paramlist {
254          parms.read_arguments(Some(self))?;
255        }
256        Ok(NO_TOKENS)
257      },
258    }
259  }
260
261  // Not implemented for expandable
262  fn invoke_primitive(&self) -> Result<Vec<Digested>> { Ok(Vec::new()) }
263  fn before_digest(&self) -> Option<&Vec<BeforeDigestClosure>> { None }
264  fn after_digest(&self) -> Option<&Vec<DigestionClosure>> { None }
265  fn do_absorption(&self, _document: &mut Document, _whatsit: &Whatsit) -> Result<Vec<Node>> {
266    fatal!(
267      Definition,
268      Unexpected,
269      "do_absorption on Expandable should never be called!"
270    );
271  }
272}
273
274impl Expandable {
275  pub fn new(
276    cs: Token,
277    paramlist: Option<Parameters>,
278    mut expansion_opt: Option<ExpansionBody>,
279    traits: Option<ExpandableOptions>,
280  ) -> Result<Self> {
281    let traits = traits.unwrap_or_default();
282    if !traits.nopack_parameters
283      && let Some(ExpansionBody::Tokens(expansion_tokens)) = expansion_opt
284    {
285      // Perl: Fatal if expansion is unbalanced (mismatched {/})
286      if !expansion_tokens.is_balanced() {
287        Error!(
288          "misdefined",
289          cs,
290          s!("Expansion of '{}' has unbalanced {{}}", cs),
291          "skipping pack_parameters"
292        );
293        // Store as-is without packing
294        expansion_opt = Some(ExpansionBody::Tokens(expansion_tokens));
295      } else {
296        expansion_opt = Some(ExpansionBody::Tokens(expansion_tokens.pack_parameters()?));
297      }
298    }
299    let has_cc_arg = match expansion_opt {
300      Some(ExpansionBody::Tokens(ref tks)) => tks
301        .unlist_ref()
302        .iter()
303        .any(|t| t.get_catcode() == Catcode::ARG),
304      _ => false,
305    };
306    // simplify: treat empty tokens as None
307    let expansion = match expansion_opt {
308      Some(ExpansionBody::Tokens(tks)) if tks.is_empty() => None,
309      real_body => real_body,
310    };
311
312    Ok(Expandable {
313      cs,
314      paramlist,
315      expansion,
316      // locator           => $source->getLocator,
317      // Hot path: Expandable::new fires on every \def/\edef; pin!-cached keys
318      // skip the per-call arena probe (same policy as Conditional::invoke).
319      is_protected: traits.protected || get_prefix_sym(crate::pin!("protected")),
320      is_outer: traits.outer || get_prefix_sym(crate::pin!("outer")),
321      is_long: traits.long || get_prefix_sym(crate::pin!("long")),
322      has_cc_arg,
323      alias: traits.alias,
324      ..Expandable::default()
325    })
326  }
327}
328
329#[cfg(test)]
330mod tests {
331  use super::*;
332
333  #[test]
334  fn expandable_default_flags_false() {
335    let e = Expandable::default();
336    assert!(!e.is_protected);
337    assert!(!e.is_long);
338    assert!(!e.is_outer);
339    assert!(!e.has_cc_arg);
340    assert!(e.alias.is_none());
341    assert!(e.paramlist.is_none());
342    assert!(e.expansion.is_none());
343  }
344
345  #[test]
346  fn expandable_default_has_default_cs() {
347    let e = Expandable::default();
348    // Default cs is a T_CS with empty text (produced by Token::default
349    // or similar). We can at least confirm the code is CS.
350    assert_eq!(e.cs.code, Catcode::CS);
351  }
352
353  #[test]
354  fn expandable_is_definition_and_expandable() {
355    let e = Expandable::default();
356    assert!(e.is_definition());
357    assert!(e.is_expandable());
358  }
359
360  #[test]
361  fn expandable_partial_eq_by_paramlist_and_expansion() {
362    // PartialEq ignores flags (protected/long/outer) and cs — it
363    // compares paramlist and expansion only.
364    let mut a = Expandable::default();
365    let mut b = Expandable::default();
366    // Both have paramlist=None, expansion=None → equal.
367    assert_eq!(a, b);
368    // Changing flags doesn't affect equality.
369    a.is_protected = true;
370    b.is_protected = false;
371    assert_eq!(a, b);
372  }
373
374  #[test]
375  fn expandable_get_num_args_zero_without_paramlist() {
376    let e = Expandable::default();
377    assert_eq!(e.get_num_args(), 0);
378  }
379
380  #[test]
381  fn expandable_get_parameters_none_by_default() {
382    let e = Expandable::default();
383    assert!(e.get_parameters().is_none());
384  }
385
386  #[test]
387  fn expandable_options_default_all_false() {
388    let o = ExpandableOptions::default();
389    assert!(!o.locked);
390    assert!(!o.protected);
391    assert!(!o.outer);
392    assert!(!o.long);
393    assert!(o.scope.is_none());
394    assert!(o.alias.is_none());
395    assert!(!o.mathactive);
396    assert!(!o.robust);
397    assert!(!o.nopack_parameters);
398  }
399}