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}