Skip to main content

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 `%&#10;` escape sequences that depend on exact token lengths. We instead always
546  /// produce compact, single-line output. Test `.xml` files should not contain `%&#10;`.
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}