latexml_core/tokens.rs
1//! Token List constructors.
2use std::{borrow::Cow, collections::VecDeque, fmt::Display, rc::Rc};
3
4#[cfg(feature = "codegen")]
5use proc_macro2::{Ident, Punct, Spacing, Span, TokenStream};
6#[cfg(feature = "codegen")]
7use quote::{ToTokens, TokenStreamExt, quote};
8
9use crate::{
10 Digested,
11 common::{
12 dimension::Dimension,
13 error::{emit_warn, *},
14 float::Float,
15 glue::Glue,
16 mudimension::MuDimension,
17 muglue::MuGlue,
18 number::Number,
19 numeric_ops::NumericOps,
20 },
21 definition::argument::ArgWrap,
22 fmt,
23 keyvals::KeyVals,
24 stomach,
25 token::*,
26};
27
28/// If untex is requested to add line-breaks, this is the line length it will allow
29pub const UNTEX_LINELENGTH: usize = 78;
30/// Use this to avoid reallocating a new empty Vec each time you need a placeholder Tokens return
31/// value
32pub const NO_TOKENS: Tokens = Tokens(Vec::new());
33pub const NO_BORROWED_TOKENS: &Tokens = &NO_TOKENS;
34/// Tokens are a thin wrapper over a vector of Token objects
35///
36/// They are usually read from a `Mouth` and treated as an immutable interface.
37/// For access to the inner Token contents, use one of the `unlist` methods.
38#[derive(Debug, Clone, Default)]
39pub struct Tokens(Vec<Token>);
40
41impl PartialEq for Tokens {
42 fn eq(&self, other: &Tokens) -> bool {
43 self.0.len() == other.0.len() && self.0.iter().zip(other.0.iter()).all(|(a, b)| a == b)
44 }
45}
46
47/// convenience macro for assembling a Tokens object from different pieces (`Token`, `Vec<Token>`,
48/// `Tokens`)
49#[macro_export]
50macro_rules! Tokens(
51 () => ( $crate::tokens::NO_TOKENS );
52 ($( $tokens:expr_2021 ),+) => ({
53 let mut collected : Vec<$crate::token::Token> = Vec::new();
54 $(
55 let t_vec : Vec<$crate::token::Token> = $tokens.into();
56 collected.extend(t_vec);
57 )*
58 $crate::tokens::Tokens::new(collected)
59 }));
60// We also need convenient auxiliaries, including auto-casting
61impl From<Vec<Token>> for Tokens {
62 fn from(ts: Vec<Token>) -> Tokens { Tokens::new(ts) }
63}
64impl From<Tokens> for Vec<Token> {
65 fn from(ts: Tokens) -> Vec<Token> { ts.unlist() }
66}
67
68impl From<Token> for Tokens {
69 fn from(t: Token) -> Tokens { Tokens::new(vec![t]) }
70}
71impl From<&Token> for Tokens {
72 fn from(t: &Token) -> Tokens { Tokens::new(vec![*t]) }
73}
74
75// Good news: Cloning `Token` should now be cheap (due to string interning),
76// so cloning `Tokens` should be fine.
77impl From<Rc<Tokens>> for Tokens {
78 fn from(t: Rc<Tokens>) -> Tokens { (*t).clone() }
79}
80impl From<&Rc<Tokens>> for Tokens {
81 fn from(t: &Rc<Tokens>) -> Tokens { (**t).clone() }
82}
83
84impl From<Tokens> for Result<Tokens> {
85 fn from(t: Tokens) -> Result<Tokens> { Ok(t) }
86}
87impl From<Token> for Result<Tokens> {
88 fn from(t: Token) -> Result<Tokens> { Ok(t.into()) }
89}
90impl From<Token> for Vec<Token> {
91 fn from(t: Token) -> Vec<Token> { vec![t] }
92}
93
94impl From<Tokens> for Token {
95 fn from(mut ts: Tokens) -> Token {
96 if ts.0.is_empty() {
97 // Match the &Tokens impl below: empty → \relax fallback rather
98 // than panic. Callers that must see the empty case are rare and
99 // should inspect Tokens directly.
100 T_CS!("\\relax")
101 } else if ts.0.len() == 1 {
102 ts.0.remove(0)
103 } else {
104 // Prefer the first token and warn; cascading a panic here usually
105 // means a stringly-typed binding slot received a multi-token value
106 // (e.g. a macro argument coerced into a single-token slot). The
107 // first token preserves TEx's "grab a single token" semantics.
108 emit_warn(
109 "internal",
110 "tokens",
111 &format!("multi-token Tokens cast into single Token: {ts:?}"),
112 );
113 ts.0.remove(0)
114 }
115 }
116}
117
118impl<'a> From<&'a Tokens> for Token {
119 fn from(ts: &'a Tokens) -> Token {
120 if ts.0.is_empty() {
121 T_CS!("\\relax") // empty Tokens → relax fallback
122 } else if ts.0.len() == 1 {
123 ts.0[0]
124 } else {
125 emit_warn(
126 "internal",
127 "tokens",
128 &format!("multi-token Tokens cast into single Token: {ts:?}"),
129 );
130 ts.0[0]
131 }
132 }
133}
134
135impl From<Option<Tokens>> for Token {
136 fn from(ts_opt: Option<Tokens>) -> Token {
137 match ts_opt {
138 Some(ts) => ts.into(),
139 None => T_CS!("\\relax"), // None → relax, matching the empty-Tokens path
140 }
141 }
142}
143
144impl From<Token> for Option<Tokens> {
145 fn from(t: Token) -> Option<Tokens> { Some(Tokens::new(vec![t])) }
146}
147impl From<Token> for Option<Cow<'static, Tokens>> {
148 fn from(t: Token) -> Option<Cow<'static, Tokens>> { Some(Cow::Owned(Tokens::new(vec![t]))) }
149}
150
151impl Display for Tokens {
152 /// to_string is used often, and for more keyword-like reasons,
153 /// NOT for creating valid TeX (use revert or UnTeX for that!)
154 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
155 for t in &self.0 {
156 if t.code != Catcode::COMMENT {
157 write!(f, "{t}")?;
158 }
159 }
160 Ok(())
161 }
162}
163
164impl AsRef<Tokens> for Tokens {
165 fn as_ref(&self) -> &Tokens { self }
166}
167
168/// A string of **TeX markup** — text that is safe to hand back to the tokenizer.
169///
170/// It is *not* a path and *not* an input `.tex` file (`source_directory`,
171/// `--source-map` and `docs/performance/SOURCE_PROVENANCE.md` own that sense of
172/// "source"); it is the character content a [`crate::mouth::Mouth`] will read.
173///
174/// # Why the type exists
175///
176/// Flattening [`struct@Tokens`] with [`Display`] **welds control words**. TeX consumes
177/// the space that terminates a control word, so `\v S` tokenizes to `[\v][S]`;
178/// re-emitting that with `Display` gives `\vS`, a control sequence that exists in
179/// no LaTeX. [`Tokens::untex`] re-emits the space, `Display` deliberately does
180/// not — this is faithful to Perl (`Core/Tokens.pm:61 toString` joins the token
181/// strings, and `Core/Token.pm:306` returns a CS name with no trailing space),
182/// whose own comment says the result is "NOT for creating valid TeX (use revert
183/// or UnTeX for that!)".
184///
185/// Perl relies on author discipline there. It has failed three times in this
186/// port — `\bib@@names` (PR #399), `dcolumn`/`overpic` (PR #400), and the
187/// MathSciNet review path (issue 410: `MRREVIEWER = {Fran\c cois\ Digne}` became
188/// `undefined:\ccois`) — each found by a user-visible failure years after the
189/// code was written. `TeXString` makes the mistake unrepresentable instead: the
190/// tokenizing sinks take `impl Into<TeXString>`, and a bare `String` has no way
191/// in.
192///
193/// # The three ways in
194///
195/// * `From<&'static str>` — a string *literal* in a binding is TeX its author
196/// typed by hand, so it converts implicitly and the ~125 literal call sites
197/// stay untouched. There is deliberately **no** `From<String>` and **no**
198/// `From<&str>`: those are exactly the shapes a welded `Tokens::to_string()`
199/// arrives in, and `s!(…)`/`format!(…)` returns the former.
200/// * [`Tokens::untex_string`] — the blessed path from `Tokens`.
201/// * [`TeXString::assembled`] — the explicit escape hatch, for a `format!` of
202/// literal TeX around already-safe pieces. It names the obligation it imposes.
203///
204/// ```
205/// # use latexml_core::tokens::TeXString;
206/// let s: TeXString = r"\relax".into(); // literal: implicit
207/// assert_eq!(s.as_str(), r"\relax");
208/// ```
209///
210/// A `String` cannot get in on its own — this is the guard, and it bites:
211///
212/// ```compile_fail
213/// # use latexml_core::tokens::TeXString;
214/// let welded: String = String::from(r"\vS");
215/// let _: TeXString = welded.into(); // no `From<String>`: does not compile
216/// ```
217#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
218pub struct TeXString(Cow<'static, str>);
219
220impl TeXString {
221 /// Assert that an owned `String` is valid TeX markup.
222 ///
223 /// **The caller's obligation**: every interpolated fragment must be either
224 /// literal TeX written at the call site, or a fragment that came from
225 /// [`Tokens::untex_string`] / another `TeXString`. It must **not** be a bare
226 /// `Tokens::to_string()` — that is the welding bug this type exists to
227 /// prevent (`\v S` → `\vS`); use [`Tokens::untex_string`] for those.
228 ///
229 /// The typical honest use is a `format!` whose *shape* is literal TeX:
230 ///
231 /// ```
232 /// # use latexml_core::tokens::TeXString;
233 /// let counter = "section";
234 /// let tex = TeXString::assembled(format!(r"\the{counter}"));
235 /// assert_eq!(tex.as_str(), r"\thesection");
236 /// ```
237 pub fn assembled(tex: String) -> Self { TeXString(Cow::Owned(tex)) }
238
239 /// The TeX markup, borrowed.
240 pub fn as_str(&self) -> &str { &self.0 }
241
242 /// The TeX markup, owned (allocates only when this was built from a literal).
243 pub fn into_string(self) -> String { self.0.into_owned() }
244
245 /// Is there any markup at all?
246 pub fn is_empty(&self) -> bool { self.0.is_empty() }
247}
248
249impl From<&'static str> for TeXString {
250 /// A `&'static str` in a binding is a TeX literal its author typed by hand.
251 ///
252 /// Deliberately the *only* blanket string conversion — see the type docs.
253 fn from(tex: &'static str) -> Self { TeXString(Cow::Borrowed(tex)) }
254}
255
256impl Display for TeXString {
257 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.0) }
258}
259
260impl AsRef<str> for TeXString {
261 fn as_ref(&self) -> &str { &self.0 }
262}
263
264impl Tokens {
265 /// Create a Tokens object from a `Vec` of individual `Token`
266 pub fn new(tokens: Vec<Token>) -> Self { Tokens(tokens) }
267
268 /// Return a list of the tokens making up this Tokens
269 pub fn unlist(self) -> Vec<Token> { self.0 }
270
271 /// Return a reference to the tokens making up this Tokens
272 pub fn unlist_ref(&self) -> &Vec<Token> { &self.0 }
273
274 /// Return a mutable reference to the tokens making up this Tokens
275 pub fn unlist_mut(&mut self) -> &mut Vec<Token> { &mut self.0 }
276
277 /// Are there any tokens at all contained in this Tokens object
278 pub fn is_empty(&self) -> bool { self.0.is_empty() }
279
280 /// Number of contained Token entries
281 pub fn len(&self) -> usize { self.0.len() }
282
283 /// Zero-alloc equivalent of `self.to_string().starts_with(prefix)`.
284 /// Walks tokens byte-by-byte into `prefix` using the same Display
285 /// semantics as `eq_text` (COMMENT skipped, ARG prefixed with `#`).
286 /// Returns `true` once the full prefix has been consumed, even if
287 /// more token text follows.
288 pub fn starts_with_text(&self, prefix: &str) -> bool {
289 let mut remaining = prefix;
290 for t in &self.0 {
291 if remaining.is_empty() {
292 return true;
293 }
294 if t.code == Catcode::COMMENT {
295 continue;
296 }
297 if t.code == Catcode::ARG {
298 if !remaining.starts_with('#') {
299 return false;
300 }
301 remaining = &remaining[1..];
302 if remaining.is_empty() {
303 return true;
304 }
305 }
306 let keep_going = t.with_str(|text| {
307 if text.is_empty() {
308 return true;
309 }
310 if remaining.starts_with(text) {
311 remaining = &remaining[text.len()..];
312 true
313 } else if text.starts_with(remaining) {
314 // This token's text extends past `prefix` — prefix matches
315 // and we're done.
316 remaining = "";
317 true
318 } else {
319 false
320 }
321 });
322 if !keep_going {
323 return false;
324 }
325 if remaining.is_empty() {
326 return true;
327 }
328 }
329 remaining.is_empty()
330 }
331
332 /// Zero-alloc equivalent of `self.to_string() == target`. Walks the
333 /// contained tokens byte-by-byte, skipping COMMENT tokens (matching
334 /// `Display for Tokens`) and prefixing ARG tokens with `#` (matching
335 /// `Display for Token`). Returns `true` iff the rendered text exactly
336 /// equals `target`. Used by DefMacro bodies that check keyword
337 /// values like `true` / `false` / `swapnumber` without wanting to
338 /// allocate a fresh `String` per invocation.
339 pub fn eq_text(&self, target: &str) -> bool {
340 let mut remaining = target;
341 for t in &self.0 {
342 if t.code == Catcode::COMMENT {
343 continue;
344 }
345 if t.code == Catcode::ARG {
346 if !remaining.starts_with('#') {
347 return false;
348 }
349 remaining = &remaining[1..];
350 }
351 let ok = t.with_str(|text| {
352 if remaining.starts_with(text) {
353 remaining = &remaining[text.len()..];
354 true
355 } else {
356 false
357 }
358 });
359 if !ok {
360 return false;
361 }
362 }
363 remaining.is_empty()
364 }
365
366 // Just a synonym for unlist in this reversion case
367 pub fn revert(self) -> Vec<Token> { self.0 }
368
369 /// to_number casts back to a parsed Number (usually via gullet::read_number)
370 /// which had to be re-converted to a Tokens for reentering the expansion flow
371 pub fn to_number(&self) -> Number {
372 if self.is_empty() {
373 log::debug!("to_number called on empty Tokens — returning 0 (TeX-compatible default)");
374 Number::default()
375 } else {
376 Number::new(self.to_string().parse::<i64>().unwrap_or(0))
377 }
378 }
379
380 /// to_dimension casts back to a parsed Dimension (usually via gullet::read_dimension)
381 /// which had to be re-converted to a Tokens for reentering the expansion flow
382 pub fn to_dimension(&self) -> Dimension {
383 // TODO: How do we enhance here to be able to use the current font information from state::
384 // Using the state::ful variations makes it impossible to work with the From/Into standard Rust
385 // traits. Should we do stateful From/Into ?
386 Dimension::new_f64(Dimension::spec_to_f64(&self.to_string()).unwrap_or_default())
387 }
388
389 /// to_glue casts back to a parsed Glue (usually via gullet::read_glue)
390 /// which had to be re-converted to a Tokens for reentering the expansion flow
391 pub fn to_glue(&self) -> Glue {
392 let token: Token = self.into();
393 token.to_glue()
394 }
395
396 /// to_mu_glue casts back to a parsed MuGlue (usually via gullet::read_mu_glue)
397 /// which had to be re-converted to a Tokens for reentering the expansion flow
398 pub fn to_mu_glue(&self) -> MuGlue {
399 let token: Token = self.into();
400 token.to_mu_glue()
401 }
402
403 /// to_mu_dimension casts back to a parsed MuGlue (usually via gullet::read_mu_glue)
404 /// which had to be re-converted to a Tokens for reentering the expansion flow
405 pub fn to_mu_dimension(&self) -> MuDimension {
406 let token: Token = self.into();
407 token.to_mu_dimension()
408 }
409
410 /// to_float casts back to a parsed Float (usually via gullet::read_float)
411 /// which had to be re-converted to a Tokens for reentering the expansion flow
412 pub fn to_float(&self) -> Float {
413 if self.is_empty() {
414 log::debug!("to_float called on empty Tokens — returning 0.0 (TeX-compatible default)");
415 Float::default()
416 } else {
417 Float::new_f64(self.to_string().parse::<f64>().unwrap_or(0.0))
418 }
419 }
420
421 /// to_keyvals casts back to a parsed KeyVals (usually via a KeyVals parameter type)
422 /// which had to be re-converted to a Tokens for reentering the expansion flow
423 pub fn to_keyvals(&self) -> Result<KeyVals> {
424 let mut toks_iter = self.unlist_ref().iter();
425 let mut kvs = KeyVals::default();
426 while let Some(key) = toks_iter.next() {
427 key.with_str(|key_str| {
428 if let Some(value) = toks_iter.next() {
429 kvs.add_value(key_str, ArgWrap::Token(*value), false, false)
430 } else {
431 kvs.add_value(key_str, ArgWrap::Tokens(Tokens!()), false, false)
432 }
433 })?;
434 }
435 Ok(kvs)
436 }
437
438 /// Methods for overloaded ops.
439 pub fn equals(&self, other: Tokens) -> bool {
440 let self_tokens: Vec<&Token> = self
441 .0
442 .iter()
443 .filter(|t| t.code != Catcode::COMMENT && t.code != Catcode::MARKER)
444 .collect();
445 let other_tokens: Vec<&Token> = other
446 .0
447 .iter()
448 .filter(|t| t.code != Catcode::COMMENT && t.code != Catcode::MARKER)
449 .collect();
450 if self_tokens.len() != other_tokens.len() {
451 false
452 } else {
453 self_tokens
454 .into_iter()
455 .zip(other_tokens)
456 .all(|(t_self, t_other)| *t_self == *t_other)
457 }
458 }
459
460 /// returns self, for compatibility convenience with `Option`
461 pub fn unwrap_or_default(self) -> Tokens { self }
462 /// returns self, for compatibility convenience with `Option`
463 pub fn unwrap(&self) -> &Tokens { self }
464
465 /// A string form which is primarily used for error-reporting
466 pub fn stringify(&self) -> String {
467 s!(
468 "Tokens[{}]",
469 &self
470 .0
471 .iter()
472 .map(ToString::to_string)
473 .collect::<Vec<_>>()
474 .join(",")
475 )
476 }
477 /// digest the current `Tokens`
478 pub fn be_digested(self) -> Result<Digested> { stomach::digest(self) }
479
480 /// neutralize each token
481 pub fn neutralize(self, extraspecials: &[char]) -> Tokens {
482 Tokens(
483 self
484 .0
485 .into_iter()
486 .map(|t| t.neutralize(extraspecials))
487 .collect::<Vec<_>>(),
488 )
489 }
490 /// Checks if any BEGIN/END code groups are correctly nested and closed
491 pub fn is_balanced(&self) -> bool {
492 let mut level = 0;
493 for t in &self.0 {
494 level += match t.get_catcode() {
495 Catcode::BEGIN => 1,
496 Catcode::END => -1,
497 _ => 0,
498 };
499 if level < 0 {
500 // a negative level encountered at any point is ill-formed,
501 // return early
502 return false;
503 }
504 }
505 level == 0
506 }
507
508 // NOTE: Assumes each arg either undef or also Tokens
509 // Using inline accessors on those assumptions
510 /// substitutes the parameters (ARG catcode) in a Tokens list for concrete arguments
511 pub fn substitute_parameters(&self, args: &[Option<Cow<Tokens>>]) -> Self {
512 // Pre-size: the substituted result is at least as long as the
513 // template. Expansion bodies can be thousands of tokens in the
514 // expl3 kernel; pre-allocation skips the first several Vec doublings.
515 let mut result = Vec::with_capacity(self.0.len());
516 for token in self.0.iter() {
517 if token.get_catcode() != Catcode::ARG {
518 // Non-match; copy it
519 result.push(*token);
520 } else {
521 let idx = token.with_str(|ts| ts.parse::<usize>().unwrap_or(0));
522 if idx > 0
523 && idx <= args.len()
524 && let Some(ref arg) = args[idx - 1]
525 {
526 // `arg` is `Cow<Tokens>`; iterate via `unlist_ref` + copy
527 // (Tokens is a Vec<Token> of `Copy` tokens). Avoids the
528 // previous `clone().into_owned().unlist()` chain which
529 // double-cloned the Vec when `arg` was `Cow::Borrowed`.
530 result.extend(arg.as_ref().unlist_ref().iter().copied());
531 }
532 }
533 }
534 Tokens::new(result)
535 }
536
537 /// Consumes a Tokens to a string containing TeX that created it (or could have).
538 /// Note that this is not necessarily the original TeX code; expansions or other substitutions may
539 /// have taken place.
540 ///
541 /// **Design decision:** The Perl `UnTeX` inserts `%\n` line-breaks (TeX comment + newline) when
542 /// a token string would exceed 78 characters. The Rust port deliberately omits this feature.
543 /// Line-break insertion is purely cosmetic and makes test expectations fragile — the `%\n`
544 /// appears verbatim in `tex=` attributes of `ltx:Math` elements, causing test XML files to
545 /// contain `% ` escape sequences that depend on exact token lengths. We instead always
546 /// produce compact, single-line output. Test `.xml` files should not contain `% `.
547 pub fn untex(self) -> String {
548 // `VecDeque::from(Vec)` reuses the Vec's heap buffer directly
549 // (no second allocation), unlike `.into_iter().collect()`.
550 let mut tokens: VecDeque<Token> = VecDeque::from(self.revert());
551 let mut tex_string = String::new();
552 let mut length = 0;
553 let mut level = 0;
554 let mut prevs = String::new();
555 let mut prevcc = Catcode::COMMENT;
556 while let Some(token) = tokens.pop_front() {
557 let cc = token.get_catcode();
558 if cc == Catcode::COMMENT {
559 continue;
560 }
561 let mut token_string = token.to_string();
562 // Note: \n only-used to fail alphanumeric test
563 let first_char = token_string.chars().next().unwrap_or('\n');
564 if cc == Catcode::LETTER {
565 // keep "words" together, just for aesthetics
566 while !tokens.is_empty() && tokens[0].get_catcode() == Catcode::LETTER {
567 tokens
568 .pop_front()
569 .unwrap()
570 .with_str(|front_str| token_string.push_str(front_str));
571 }
572 }
573
574 let l = token_string.len();
575 if cc == Catcode::BEGIN {
576 level += 1;
577 }
578 // Seems a reasonable & safe time to line break, for readability, etc.
579 if cc == Catcode::SPACE && token_string == "\n" {
580 // preserve newlines already present
581 if length > 0 {
582 tex_string.push_str(&token_string);
583 length = 0;
584 }
585 // If this token is a letter (or otherwise starts with a letter or digit): space or linebreak
586 } else {
587 let last_prevs = prevs.chars().last().unwrap_or('_');
588 // Perl: $STATE->lookupCatcode($1) == CC_LETTER
589 // Must use actual catcode lookup, not just is_alphabetic(), because
590 // characters like @ may have catcode LETTER in some contexts.
591 let prev_is_letter = crate::state::lookup_catcode(last_prevs)
592 .map(|cc| cc == Catcode::LETTER)
593 .unwrap_or_else(|| last_prevs.is_alphabetic());
594
595 if (cc == Catcode::LETTER || (cc == Catcode::OTHER && first_char.is_alphanumeric()))
596 && prevcc == Catcode::CS
597 && prev_is_letter
598 {
599 // Insert a (virtual) space before a letter if previous token was a CS w/letters
600 // This is required for letters, but just aesthetic for digits (to me?)
601 let space = ' ';
602 tex_string.push(space);
603 tex_string.push_str(&token_string);
604 length += 1 + l;
605 } else {
606 tex_string.push_str(&token_string);
607 length += l;
608 }
609 if cc == Catcode::END {
610 level -= 1;
611 }
612 prevs = token_string;
613 prevcc = cc;
614 }
615 }
616 // Patch up nesting for valid TeX !!!
617 match level {
618 1..=i32::MAX => {
619 for _ in 0..level {
620 tex_string.push('}');
621 }
622 },
623 i32::MIN..=-1 => {
624 // Prepend `-level` opening braces in one alloc (was O(n²) with
625 // String::from("{") + &tex_string per iteration).
626 let n = (-level) as usize;
627 let mut prefixed = String::with_capacity(n + tex_string.len());
628 for _ in 0..n {
629 prefixed.push('{');
630 }
631 prefixed.push_str(&tex_string);
632 tex_string = prefixed;
633 },
634 0 => {},
635 }
636 tex_string
637 }
638
639 /// [`untex`](Tokens::untex), typed — the blessed way to get TeX markup out of
640 /// a `Tokens` and back into a tokenizing sink.
641 ///
642 /// Prefer this over `untex()` whenever the string is destined for
643 /// `Tokenize!` / `mouth::tokenize` / `mouth::tokenize_internal`: it is the
644 /// only [`struct@Tokens`]→[`TeXString`] conversion, so the sink's signature proves
645 /// the round trip cannot weld a control word (`\v S` stays `\v S`, not
646 /// `\vS`). `untex()` itself is unchanged for the callers that want a plain
647 /// `String` (a `tex=` attribute, a log message, a comparison).
648 pub fn untex_string(self) -> TeXString { TeXString::assembled(self.untex()) }
649
650 /// Packs repeated CC_PARAM tokens into CC_ARG tokens for use as a macro body (and other token
651 /// lists) Also unwraps \noexpand tokens, since that is also needed for macro bodies
652 /// (but not strictly part of packing parameters)
653 pub fn pack_parameters(self) -> Result<Self> {
654 // Result is at most the same size as input (param-digit pairs
655 // collapse 2→1; other tokens copy 1→1). Pre-sizing avoids the
656 // initial Vec doublings on 1k+ token expansions (common for
657 // expl3 macros).
658 let mut rescanned = Vec::with_capacity(self.0.len());
659 // `VecDeque::from(Vec)` reuses the Vec's heap buffer directly (no
660 // second allocation), unlike `into_iter().collect()` which copies.
661 let mut toks: VecDeque<Token> = VecDeque::from(self.unlist());
662 while let Some(t) = toks.pop_front() {
663 if t.get_catcode() == Catcode::PARAM && !toks.is_empty() {
664 let next_t = toks.pop_front();
665 let next_cc = next_t.as_ref().map(|t| t.get_catcode());
666 if next_cc == Some(Catcode::OTHER) {
667 // only group clear match token cases
668 rescanned.push(Token {
669 text: next_t.unwrap().get_sym(),
670 code: Catcode::ARG,
671 #[cfg(feature = "token-locators")]
672 loc: 0,
673 });
674 } else if next_cc == Some(Catcode::PARAM) {
675 rescanned.push(t);
676 } else {
677 // A PARAM (`#`) followed by neither a digit nor another `#` is, in
678 // real documents, almost always a `\halign`/`\valign` alignment-cell
679 // marker embedded in a macro body (e.g. `\def\foo{\halign{#\hfil&...}}`)
680 // or the `#{` end-of-parameter-text delimiter — both VALID TeX where
681 // the catcode-6 `#` must survive losslessly into the template/preamble.
682 // Real TeX resolves the parameter-vs-cell ambiguity during alignment
683 // processing, a lower level than LaTeXML operates at, so we cannot
684 // reliably tell this apart from a genuine typo.
685 //
686 // Perl's packParameters (Tokens.pm L139) emits a *counted* Error here
687 // AND drops both tokens, corrupting the template — but Perl rarely
688 // reaches it (it often can't find the offending package and skips the
689 // raw load). We DO raw-load such packages, so erroring+dropping broke
690 // the error-free target for the common halign-in-macro idiom. Preserve
691 // both tokens and log at Info (non-counted) instead. Documented as a
692 // beneficial divergence in docs/parity/KNOWN_PERL_ERRORS.md item 1. Witness
693 // 2006.02269 (easyeqn.sty `{MATRIX}` env → `$\mathstrut##$` template;
694 // 2 errors → 0).
695 Info!(
696 "misdefined",
697 "expansion",
698 "Lone # (catcode PARAM) preserved as alignment/template marker. In expansion {}",
699 Tokens::new(toks.clone().into_iter().collect()).to_string()
700 );
701 rescanned.push(t);
702 if let Some(nt) = next_t {
703 rescanned.push(nt);
704 }
705 }
706 } else {
707 rescanned.push(t);
708 }
709 }
710 Ok(Tokens::new(rescanned))
711 }
712
713 /// Trims outer braces (if they balance each other).
714 /// Strips exactly 1 layer of matching outer braces by default.
715 /// Should this also trim whitespace? or only if there are braces?
716 pub fn strip_braces(self) -> Self { self.strip_braces_n(1) }
717
718 /// Trims `layers` outer brace pairs (if they balance each other).
719 /// Also trims whitespace *outer to* the removed braces.
720 /// Follows the Perl Tokens.pm algorithm: first collects all balanced
721 /// brace pairs, then strips from outside-in, only removing pairs that
722 /// span the full remaining width.
723 pub fn strip_braces_n(self, mut layers: usize) -> Self {
724 let tokens = self.0;
725 let n = tokens.len();
726 if n <= 1 {
727 return Tokens::new(tokens);
728 }
729
730 let mut i0: usize = 0;
731 let mut i1: usize = n;
732
733 // skip past spaces at ends
734 while i0 < i1 && tokens[i0].get_catcode() == Catcode::SPACE {
735 i0 += 1;
736 }
737 while i1 > i0 && tokens[i1 - 1].get_catcode() == Catcode::SPACE {
738 i1 -= 1;
739 }
740
741 // Collect balanced pairs (innermost first due to stack order)
742 let mut opens: Vec<usize> = Vec::new();
743 let mut pairs: Vec<(usize, usize)> = Vec::new();
744 for i in i0..i1 {
745 match tokens[i].get_catcode() {
746 Catcode::BEGIN => opens.push(i),
747 Catcode::END => {
748 if let Some(j) = opens.pop() {
749 pairs.push((j, i));
750 } else {
751 return Tokens::new(tokens); // Unbalanced: Too many }
752 }
753 },
754 _ => {},
755 }
756 }
757 if !opens.is_empty() {
758 return Tokens::new(tokens); // Unbalanced: Too many {
759 }
760
761 // Strip layers from outside-in.
762 // pairs is ordered innermost-first, so pop() gives outermost pair first.
763 while layers > 0 {
764 layers -= 1;
765 if let Some((j0, j1)) = pairs.pop()
766 && j0 == i0
767 && j1 == i1 - 1
768 {
769 i0 += 1;
770 i1 -= 1;
771 }
772 }
773
774 // Empty after stripping
775 if i0 >= i1 {
776 return Tokens::new(Vec::new());
777 }
778
779 if i0 > 0 || i1 < n {
780 Tokens::new(tokens[i0..i1].to_vec())
781 } else {
782 Tokens::new(tokens)
783 }
784 }
785}
786
787// `impl ToTokens` blocks below are gated on the `codegen` feature
788// (audit DEP-14, 2026-05-18). They are called only at compile time by
789// `latexml_codegen` proc-macros via `quote!{ ... #tokens_value ... }`
790// splices. Resolver v2 keeps proc-macro feature unification isolated,
791// so the runtime `latexml_core` linked into `latexml_oxide` does NOT
792// compile these impls — dropping `proc-macro2` (~93 KiB) and `quote`
793// from the runtime binary's dependency graph.
794#[cfg(feature = "codegen")]
795impl ToTokens for Tokens {
796 fn to_tokens(&self, stream: &mut TokenStream) {
797 let d = &self.0;
798 stream.extend(quote! {
799 Tokens::new(<[Token]>::into_vec(Box::new([ #(#d),* ])))
800 });
801 }
802}
803
804#[cfg(feature = "codegen")]
805impl ToTokens for Catcode {
806 fn to_tokens(&self, stream: &mut TokenStream) {
807 use crate::token::Catcode::*;
808 let kind = match *self {
809 ESCAPE => "ESCAPE",
810 BEGIN => "BEGIN",
811 END => "END",
812 MATH => "MATH",
813 ALIGN => "ALIGN",
814 EOL => "EOL",
815 PARAM => "PARAM",
816 SUPER => "SUPER",
817 SUB => "SUB",
818 SPACE => "SPACE",
819 // Non-primitive
820 IGNORE => "IGNORE",
821 LETTER => "LETTER",
822 OTHER => "OTHER",
823 ACTIVE => "ACTIVE",
824 COMMENT => "COMMENT",
825 INVALID => "INVALID",
826 CS => "CS",
827 MARKER => "MARKER",
828 ARG => "ARG",
829 };
830 stream.append(Ident::new("Catcode", Span::call_site()));
831 stream.append(Punct::new(':', Spacing::Joint));
832 stream.append(Punct::new(':', Spacing::Alone));
833 stream.append(Ident::new(kind, Span::call_site()));
834 }
835}
836
837#[cfg(feature = "codegen")]
838impl ToTokens for Token {
839 fn to_tokens(&self, stream: &mut TokenStream) {
840 let code = self.get_catcode();
841 self.with_str(|text| {
842 stream.extend(quote! {
843 Token {
844 text: latexml_core::common::arena::pin_static(#text),
845 code: #code,
846 // Emitted into the consumer crate; the cfg resolves there (the feature
847 // propagates from latexml_oxide). See docs/performance/SOURCE_PROVENANCE.md §3.1.1.
848 #[cfg(feature = "token-locators")]
849 loc: 0u32
850 }
851 })
852 });
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use crate::common::arena;
860
861 fn letter_tok(s: &str) -> Token {
862 Token {
863 text: arena::pin(s),
864 code: Catcode::LETTER,
865 #[cfg(feature = "token-locators")]
866 loc: 0,
867 }
868 }
869
870 fn comment_tok(s: &str) -> Token {
871 Token {
872 text: arena::pin(s),
873 code: Catcode::COMMENT,
874 #[cfg(feature = "token-locators")]
875 loc: 0,
876 }
877 }
878
879 #[test]
880 fn empty_tokens_len_zero() {
881 let t = Tokens::new(vec![]);
882 assert_eq!(t.len(), 0);
883 assert!(t.is_empty());
884 }
885
886 #[test]
887 fn tokens_new_preserves_order() {
888 let t = Tokens::new(vec![letter_tok("a"), letter_tok("b"), letter_tok("c")]);
889 assert_eq!(t.len(), 3);
890 let list = t.unlist();
891 let texts: Vec<String> = list.iter().map(|t| arena::to_string(t.text)).collect();
892 assert_eq!(texts, vec!["a", "b", "c"]);
893 }
894
895 #[test]
896 fn tokens_unlist_ref_does_not_consume() {
897 let t = Tokens::new(vec![letter_tok("a")]);
898 let r = t.unlist_ref();
899 assert_eq!(r.len(), 1);
900 // t is still usable after unlist_ref.
901 assert_eq!(t.len(), 1);
902 }
903
904 #[test]
905 fn tokens_stringify_format() {
906 let t = Tokens::new(vec![letter_tok("a"), letter_tok("b")]);
907 let s = t.stringify();
908 assert!(s.starts_with("Tokens["), "got {s:?}");
909 assert!(s.ends_with(']'));
910 assert!(s.contains("a"));
911 assert!(s.contains("b"));
912 }
913
914 #[test]
915 fn tokens_equals_ignores_comments_and_markers() {
916 // equals() filters out COMMENT and MARKER tokens before comparing.
917 let a = Tokens::new(vec![letter_tok("x"), comment_tok("%"), letter_tok("y")]);
918 let b = Tokens::new(vec![letter_tok("x"), letter_tok("y")]);
919 assert!(a.equals(b), "comments should be ignored in equals()");
920 }
921
922 #[test]
923 fn tokens_equals_different_content() {
924 let a = Tokens::new(vec![letter_tok("x")]);
925 let b = Tokens::new(vec![letter_tok("y")]);
926 assert!(!a.equals(b));
927 }
928
929 #[test]
930 fn tokens_equals_different_lengths() {
931 let a = Tokens::new(vec![letter_tok("x")]);
932 let b = Tokens::new(vec![letter_tok("x"), letter_tok("y")]);
933 assert!(!a.equals(b));
934 }
935
936 #[test]
937 fn tokens_equals_both_empty() {
938 let a = Tokens::new(vec![]);
939 let b = Tokens::new(vec![]);
940 assert!(a.equals(b));
941 }
942
943 #[test]
944 fn tokens_unwrap_self_identity() {
945 let t = Tokens::new(vec![letter_tok("x")]);
946 assert_eq!(t.unwrap().len(), 1);
947 }
948
949 #[test]
950 fn tokens_revert_returns_vec() {
951 let t = Tokens::new(vec![letter_tok("x"), letter_tok("y")]);
952 let v = t.revert();
953 assert_eq!(v.len(), 2);
954 }
955
956 #[test]
957 fn tokens_display_joins_content() {
958 // Display on Tokens concatenates each token's Display.
959 let t = Tokens::new(vec![letter_tok("a"), letter_tok("b"), letter_tok("c")]);
960 let s = format!("{t}");
961 assert_eq!(s, "abc");
962 }
963}
964
965#[cfg(test)]
966mod untex_control_word_space_tests {
967 use super::*;
968
969 /// `untex()` must re-emit the space that terminates a control word;
970 /// `to_string()` documents that it does not, and the two must not be
971 /// confused at a call site that needs valid TeX back.
972 ///
973 /// TeX CONSUMES the space after a control word, so by token-time it is gone
974 /// as data — `\v S` and `\vS` tokenize to different things but a naive
975 /// concatenation of the first yields the second. `\vS` exists in no LaTeX.
976 ///
977 /// This is not academic: `\bib@@names` used `to_string()` where Perl uses
978 /// `UnTeX` (BibTeX.pool.ltxml L277), which mangled every space-form accent in
979 /// a bibliography author name — `{\v S}`, `{\c c}`, `{\" a}`, i.e. most
980 /// non-English names — into an undefined macro. ~+2800 error documents per
981 /// corpus on the 2026-07-26 sandbox sweep.
982 #[test]
983 fn untex_reemits_the_space_that_terminates_a_control_word() {
984 for (src, expect_untex) in [
985 (r"\v Spakov", r"\v Spakov"), // control word + space + LETTER: space needed
986 (r"\c calves", r"\c calves"), // likewise
987 (r"\v{S}pakov", r"\v{S}pakov"), // braced argument: no space needed, none added
988 ] {
989 let toks = crate::mouth::tokenize(src);
990 assert_eq!(
991 toks.clone().untex(),
992 expect_untex,
993 "untex({src:?}) must round-trip to valid TeX"
994 );
995 // Re-tokenizing the untex output must yield the SAME control sequence —
996 // the property that actually matters, and the one that broke.
997 let first = |t: Tokens| {
998 t.unlist()
999 .first()
1000 .map(|t| t.to_string())
1001 .unwrap_or_default()
1002 };
1003 assert_eq!(
1004 first(crate::mouth::tokenize(toks.clone().untex_string())),
1005 first(crate::mouth::tokenize(src)),
1006 "untex({src:?}) changed the leading control sequence on re-tokenization"
1007 );
1008 }
1009 }
1010
1011 /// The documented contract of the other direction, pinned so nobody
1012 /// "helpfully" makes `Display` TeX-correct and silently changes every
1013 /// keyword-ish `to_string()` caller in the tree.
1014 #[test]
1015 fn to_string_deliberately_does_not_reemit_the_space() {
1016 let toks = crate::mouth::tokenize(r"\v Spakov");
1017 assert_eq!(
1018 toks.to_string(),
1019 r"\vSpakov",
1020 "Display for Tokens is documented as NOT producing valid TeX; if this \
1021 changes, audit every to_string() caller before updating the expectation"
1022 );
1023 }
1024}
1025
1026/// The guard itself: proof, at COMPILE time, that a welded `String` cannot reach
1027/// a tokenizing sink.
1028///
1029/// This module contains no runtime assertions worth the name — its whole value is
1030/// that it stops compiling if the property is lost. It is `#[cfg(test)]`, so it is
1031/// checked whenever the test target is built (`cargo test --tests`, and so CI).
1032#[cfg(test)]
1033mod texstring_guard_tests {
1034 use super::*;
1035
1036 /// Neither `String: Into<TeXString>` nor `&String: Into<TeXString>` may hold.
1037 ///
1038 /// Those are the shapes a control-word-welding `Tokens::to_string()` arrives in
1039 /// (`s!(…)`/`format!(…)` gives the first, `&some_local` the second), so an
1040 /// implicit conversion would silently reopen the bug this type exists to close
1041 /// (`\bib@@names` PR #399, `dcolumn`/`overpic` PR #400, MathSciNet review path
1042 /// issue 410).
1043 ///
1044 /// Mechanism (the `static_assertions::assert_not_impl_any!` trick, hand-rolled
1045 /// to avoid the dependency): a helper trait with a blanket impl for everything
1046 /// plus a second impl gated on `Into<TeXString>`. If BOTH apply the item path is
1047 /// ambiguous and this fails to compile; if only the blanket one applies it
1048 /// resolves. So "still compiles" == "the conversion does not exist".
1049 ///
1050 /// A `&str` whose lifetime is shorter than `'static` is rejected too, but not
1051 /// by this assertion — an unconstrained `&'_ str` here infers to `&'static str`,
1052 /// which is exactly the case that MUST convert. The compiler enforces that half
1053 /// at the call site instead, as an E0521 "borrowed data escapes … `'1` must
1054 /// outlive `'static`".
1055 const _NO_IMPLICIT_STRING_CONVERSION: fn() = || {
1056 trait AmbiguousIfConvertible<A> {
1057 fn some_item() {}
1058 }
1059 impl<T> AmbiguousIfConvertible<()> for T {}
1060 impl<T: Into<TeXString>> AmbiguousIfConvertible<u8> for T {}
1061
1062 let _ = <String as AmbiguousIfConvertible<_>>::some_item;
1063 let _ = <&String as AmbiguousIfConvertible<_>>::some_item;
1064 };
1065
1066 /// The other half: a `&'static str` — i.e. every TeX literal a binding types
1067 /// out — must keep converting implicitly, or ~125 call sites would need
1068 /// ceremony for nothing.
1069 const _LITERALS_STILL_CONVERT: fn() = || {
1070 fn sink(_: impl Into<TeXString>) {}
1071 sink(r"\relax");
1072 sink(TeXString::assembled(String::new()));
1073 };
1074
1075 /// …while the three blessed ways in must all still work. Kept beside the
1076 /// negative assertion so a "fix" that deletes the conversions to satisfy it is
1077 /// caught here.
1078 #[test]
1079 fn the_three_blessed_constructors_reach_the_sink() {
1080 // 1. a literal
1081 assert_eq!(crate::mouth::tokenize(r"\relax").to_string(), r"\relax");
1082 // 2. untex_string — the space that terminates `\v` survives the round trip
1083 let welded = crate::mouth::tokenize(r"\v Spakov");
1084 assert_eq!(
1085 crate::mouth::tokenize(welded.clone().untex_string()).to_string(),
1086 r"\vSpakov",
1087 "re-tokenizing untex_string() output must give back the SAME tokens \
1088 (whose Display is again the welded form) — i.e. \\v stayed \\v"
1089 );
1090 // …which is precisely what the welded string does NOT do:
1091 let welded_again = crate::mouth::tokenize(TeXString::assembled(welded.to_string()));
1092 assert_eq!(
1093 welded_again.unlist().first().map(|t| t.to_string()),
1094 Some(r"\vSpakov".to_string()),
1095 "the welded path collapses \\v + Spakov into the single undefined CS \
1096 \\vSpakov — the bug TeXString exists to make hard to write"
1097 );
1098 // 3. assembled
1099 assert_eq!(
1100 crate::mouth::tokenize(TeXString::assembled(format!(r"\the{}", "section"))).to_string(),
1101 r"\thesection"
1102 );
1103 }
1104}