Skip to main content

latexml_core/
mouth.rs

1use core::ops::RangeBounds;
2use std::{
3  collections::VecDeque,
4  fmt,
5  fs::File,
6  io,
7  io::{BufReader, prelude::*},
8  str,
9};
10
11// TODO:
12// use encoding::all::ISO_8859_1;
13// use encoding::{EncoderTrap, Encoding};
14use once_cell::sync::Lazy;
15use regex::Regex;
16
17use crate::{
18  common::{
19    arena::SymStr,
20    error::{emit_warn, *},
21    locator::Locator,
22    numeric_ops::NumericOps,
23    object::Object,
24  },
25  state::*,
26  token::*,
27  tokens::{NO_TOKENS, TeXString, Tokens},
28  util::pathname,
29};
30
31static TRAILING_SPACE_CHARS: Lazy<Regex> = Lazy::new(|| Regex::new("(?s) +$").unwrap());
32
33const READLINE_PROGRESS_QUANTUM: usize = 25;
34
35#[derive(PartialEq, Eq, Debug, Copy, Clone)]
36pub enum FoodType {
37  File,
38  // Binding,
39  HTTP,
40  HTTPS,
41  Literal,
42}
43
44impl FoodType {
45  /// TODO: Should be a From trait implementation, but am not allowed due to both &str and Option
46  /// being external. Argh.
47  pub fn opt_from_str(text: &str) -> Option<FoodType> {
48    use self::FoodType::*;
49    match text.to_lowercase().as_str() {
50      "file" => Some(File),
51      // "binding" => Some(Binding),
52      "http" => Some(HTTP),
53      "https" => Some(HTTPS),
54      "literal" => Some(Literal),
55      _ => None,
56    }
57  }
58}
59
60static LINEBREAK_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s:\r\n?)|(?s:\n)").unwrap());
61// LOWERHEX_REGEX removed — replaced with direct matches!() check in tex_hex_caret path.
62static _SANITIZE_LINE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"((\\ )*)\s*$").unwrap());
63
64#[derive(Debug, Default)]
65pub struct MouthOptions {
66  pub fordefinitions: bool,
67  pub at_letter:      bool,
68  pub notes:          bool,
69  pub content:        Option<String>,
70  pub foodtype:       Option<FoodType>,
71  pub source:         Option<String>,
72  pub shortsource:    Option<String>,
73}
74
75#[derive(Debug)]
76pub struct Mouth {
77  fordefinitions:         bool,
78  at_letter:              bool,
79  notes:                  bool,
80  at_eof:                 bool,
81  nchars:                 usize,
82  colno:                  usize,
83  lineno:                 usize,
84  /// Source start `(lineno, colno)` of the most recently *begun* token,
85  /// captured in `read_token` after inter-token skips and before the token's
86  /// first char is consumed. Foundation for §1 accurate construct-start ranges
87  /// (docs/performance/SOURCE_PROVENANCE.md). Semantically inert until consumed by a ranged
88  /// locator; written unconditionally — two writes, below the hot-path noise
89  /// floor and cheaper than a per-read flag check.
90  last_token_start:       (usize, usize),
91  foodtype:               FoodType,
92  /// Read `% & #` as ordinary characters — not comment, alignment tab,
93  /// parameter — for this mouth only. Set by `with_bib_data_literals()`; see
94  /// that method for the BibTeX rationale, and for why `_` is NOT here.
95  /// Deliberately a per-Mouth field rather than a State catcode assignment: a
96  /// nested mouth (a `.sty` raw-load triggered from inside the text) is a
97  /// separate object and keeps TeX's meanings, which a State-level assignment
98  /// could not guarantee.
99  bib_data_literals:      bool,
100  saved_at_cc:            Option<Catcode>,
101  saved_include_comments: Option<bool>,
102  note_message:           Option<String>,
103  source:                 String,
104  /// `source` pre-interned at construction, so per-token / per-conditional
105  /// locator building ([`Object::get_locator`], [`Mouth::get_locator_from_start`])
106  /// is pure field copies instead of an interner probe over the path string.
107  /// `source` is never mutated after construction, so the two cannot drift.
108  source_sym:             SymStr,
109  shortsource:            String,
110  skipping_spaces:        bool,
111  // pub handle : Option<File>,
112  chars:                  VecDeque<char>,
113  buffer:                 VecDeque<String>,
114  raw_buffer:             VecDeque<Vec<u8>>,
115  reader:                 Option<BufReader<File>>,
116}
117
118impl PartialEq for Mouth {
119  fn eq(&self, other: &Mouth) -> bool { self.source == other.source }
120}
121
122impl Default for Mouth {
123  fn default() -> Self {
124    // Historically the source was `"Anonymous String {gid}"` with a
125    // per-instance gid, which Locator::source then pinned into the arena.
126    // The gid served no functional purpose and made every anonymous mouth
127    // unique at the SymStr layer — fine for a handful of mouths, but
128    // catastrophic when a runaway error-recovery path creates millions
129    // (arxiv 1210.4211 under parallel load: 50M anonymous mouths saturated
130    // the u32 interner offset). Collapsing onto a shared static label makes
131    // the per-mouth cost arena-free, and the pin-count sentinel remains as
132    // a symptom detector for the *actual* bug (something is still creating
133    // 50M anonymous mouths — that's a runaway loop to track down, now with
134    // the arena side-effect removed).
135    Mouth {
136      notes:                  false,
137      note_message:           None,
138      fordefinitions:         false,
139      at_letter:              false,
140      at_eof:                 false,
141      skipping_spaces:        false,
142      lineno:                 0,
143      colno:                  0,
144      last_token_start:       (0, 0),
145      chars:                  VecDeque::new(),
146      nchars:                 0,
147      source:                 String::from("Anonymous String"),
148      source_sym:             crate::pin!("Anonymous String"),
149      shortsource:            s!("String"),
150      // handle : None,
151      foodtype:               FoodType::File,
152      bib_data_literals:      false,
153      saved_at_cc:            None,
154      saved_include_comments: None,
155      buffer:                 VecDeque::new(),
156      raw_buffer:             VecDeque::new(),
157      reader:                 None,
158    }
159  }
160}
161
162impl fmt::Display for Mouth {
163  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Mouth[{}]", self.source) }
164}
165impl Object for Mouth {
166  fn stringify(&self) -> String { s!("Mouth[<string>{}x{}]", self.lineno, self.colno) }
167  fn get_locator(&self) -> Option<Locator> {
168    let (to_line, to_column) = (self.lineno, self.colno);
169    let max_col = if self.nchars > 0 {
170      self.nchars - 1
171    } else {
172      self.nchars
173    }; // There is always a trailing EOL char, if any
174    let (from_line, from_column) = if to_column > 0 && to_column >= max_col {
175      (to_line, 0)
176    } else {
177      (to_line, to_column)
178    };
179    // Perl Mouth.pm L199 (#2671): columns in Locator are 1-indexed; the Mouth's
180    // internal colno counter is 0-indexed (character array index), so we add 1
181    // when producing the Locator for error-message display.
182    // A Mouth always has a position, so this is always `Some`.
183    Some(Locator::from_sym(
184      self.source_sym,
185      from_line as u32,
186      (from_column + 1) as u32,
187      to_line as u32,
188      (to_column + 1) as u32,
189    ))
190  }
191}
192
193/// Decode raw input bytes as text when no encoding has been declared:
194/// valid UTF-8 is taken as-is, anything else is a Latin-1 passthrough
195/// (byte → char).
196///
197/// Mirrors Perl `Mouth.pm` L75-80: when `PERL_INPUT_ENCODING` is undef Perl
198/// never decodes, so the bytes pass through untouched and the read cannot
199/// fail. The point is that a non-UTF-8 file is **never lost** — only decoded
200/// conservatively. `std::fs::read_to_string` gives the opposite behaviour
201/// (hard error on the first stray byte), which silently cost witness
202/// 2605.00490 its entire bibliography: a JabRef-written `.bib` self-declaring
203/// `% Encoding: Cp1252`. Real `bibtex` 0.99d is 8-bit clean and reads it fine.
204///
205/// Latin-1 (rather than `from_utf8_lossy`) is the better fallback here
206/// because it is lossless byte → char: legacy `.bib` files are overwhelmingly
207/// Latin-1/Cp1252, whose accented names survive intact instead of collapsing
208/// to U+FFFD.
209///
210/// The fallback is applied **per line**, not per buffer. `raw` is a single
211/// line when the Mouth calls this, but a whole file when a `.bib` reader does,
212/// and decoding a whole file as Latin-1 because of one stray byte would
213/// mojibake every correctly-UTF-8-encoded name in it (`é` → `é`). Per-line
214/// keeps the damage to the offending line and matches the Mouth's own
215/// granularity. The all-valid-UTF-8 case (the overwhelming majority) still
216/// costs exactly one `from_utf8` SIMD validation of the whole buffer.
217pub fn decode_input_bytes(raw: &[u8]) -> String {
218  match str::from_utf8(raw) {
219    Ok(s) => s.to_string(),
220    Err(_) => {
221      let mut out = String::with_capacity(raw.len());
222      for (i, line) in raw.split(|&b| b == b'\n').enumerate() {
223        if i > 0 {
224          out.push('\n');
225        }
226        match str::from_utf8(line) {
227          Ok(s) => out.push_str(s),
228          Err(_) => out.extend(line.iter().map(|&b| b as char)),
229        }
230      }
231      out
232    },
233  }
234}
235
236impl Mouth {
237  // Factory method;
238  // Create an appropriate Mouth
239  // options are
240  //  quiet,
241  //  atletter,
242  //  content
243  //
244  // DG: For now we are using a `foodtype` field instead of subclassing mouth, as it feels more
245  // compact in this particular application     we're really looking at a unified Mouth
246  // application logic, with a capacity of reading different kinds of sources
247  pub fn create(source: &str, mut options: MouthOptions) -> Result<Self> {
248    if let Some(content) = options.content.take() {
249      // we've cached the content of this source
250      let (_dir, name, ext) = pathname::split(source);
251      options.source = Some(source.to_string());
252      options.shortsource = Some(s!("{}.{}", name, ext));
253      // Read-log: a named cached-content open (filecontents / LSP overlay).
254      record_opened_source(crate::common::arena::pin(source));
255      Mouth::new(&content, Some(options))
256    } else if source.starts_with("literal:") {
257      let source = source.replacen("literal:", "", 1);
258      // we've supplied literal data
259      options.source = None; // the source does not have a corresponding file name
260      options.foodtype = FoodType::opt_from_str("literal");
261      Mouth::new(&source, Some(options))
262    } else if source.is_empty() {
263      Mouth::new("", Some(options))
264    } else {
265      let (_dir, name, ext) = pathname::split(source);
266      options.foodtype = FoodType::opt_from_str(&pathname::protocol(source));
267      options.source = Some(source.to_string());
268      if options.shortsource.is_none() {
269        options.shortsource = Some(if ext.is_empty() {
270          name
271        } else {
272          s!("{}.{}", name, ext)
273        });
274      }
275      // Read-log: a named file open (recorded even when the open then
276      // fails — a pinned-but-missing path that later APPEARS must read
277      // as a dependency change).
278      record_opened_source(crate::common::arena::pin(source));
279      Mouth::new(source, Some(options))
280    }
281  }
282
283  /// What kind of source feeds this mouth (file vs literal/string injection).
284  pub fn foodtype(&self) -> FoodType { self.foodtype }
285
286  pub fn new(text: &str, options: Option<MouthOptions>) -> Result<Self> {
287    let mut mouth = match options {
288      None => Mouth {
289        foodtype: FoodType::Literal,
290        ..Mouth::default()
291      },
292      Some(opts) => {
293        let shortsource = opts.shortsource.unwrap_or_else(|| s!("String"));
294        let source = opts.source.unwrap_or_default();
295        Mouth {
296          foodtype: opts.foodtype.unwrap_or(FoodType::Literal),
297          fordefinitions: opts.fordefinitions,
298          at_letter: opts.at_letter,
299          notes: opts.notes,
300          source_sym: crate::common::arena::pin(&source),
301          source,
302          shortsource,
303          ..Mouth::default()
304        }
305      },
306    };
307    mouth.open(text)?;
308    Ok(mouth)
309  }
310
311  /// Read `% & #` as ordinary characters (catcode 12) instead of comment,
312  /// alignment tab and parameter, for the whole life of this mouth.
313  ///
314  /// **Treatment 1 of two** (see `OXIDIZED_DESIGN #74`): this is "be `bibtex`".
315  /// BibTeX's lexer interprets only braces and the entry/field delimiters — it
316  /// has no comment syntax inside an entry (`%` is significant only in the junk
317  /// BETWEEN entries, `Pre::BibTeX::skipJunk`), no alignment and no parameters.
318  /// So a field value it hands back is a string in which all three are ordinary
319  /// characters: a percent-encoded URL, a publisher's name ("Taylor &
320  /// Francis"), an issue number.
321  ///
322  /// Re-injected as TeX source (BibTeX.pool's `\bibentry@create`) under the
323  /// default catcodes, each misfires: `%` (14) comments out the rest of its
324  /// line — the field's own closing brace included — so the entry's group never
325  /// closes; `&` (4) is a stray alignment tab and is dropped; `#` (6) reaches
326  /// the Stomach as a parameter token. Reading the injected text with all three
327  /// neutralized preserves the value BibTeX actually parsed, **without altering
328  /// a byte of it**.
329  ///
330  /// **`_` is deliberately NOT in this set**, and the reason is the boundary
331  /// between the two treatments. A catcode is decided at tokenization, before
332  /// anything knows whether it is inside `$…$` — and a subscript in a `.bib`
333  /// title's math (`title = {Bounds on $x_1+x_2$}`) is *legitimate TeX* that
334  /// must keep working. `_` therefore belongs to treatment 2
335  /// (`bibtex.rs::escape_bib_data_specials`), which walks the value and skips
336  /// math spans. Measured: putting `_` here silently flattened every
337  /// subscript in a bibliography title. The other three have no legitimate
338  /// meaning inside a `.bib` field, in math or out.
339  ///
340  /// A `\catcode` in the injected text cannot do this job either: the catcode
341  /// would still be a State assignment, so a raw `.sty` opened from inside a
342  /// field handler would inherit it — and so would the document. Scoping to the
343  /// Mouth keeps the rule attached to the *text that BibTeX lexed*, which is
344  /// exactly where it belongs.
345  ///
346  /// Only the TeX-special meaning is removed: a character that has been given
347  /// some other catcode (LETTER, say) keeps it. And `\%`, `\&`, `\#` still
348  /// work, because the backslash is untouched.
349  pub fn with_bib_data_literals(mut self) -> Self {
350    self.bib_data_literals = true;
351    self
352  }
353
354  pub fn get_source(&self) -> &str { &self.source }
355
356  pub fn open(&mut self, content: &str) -> Result<()> {
357    match self.foodtype {
358      FoodType::File => self.open_file(content)?,
359      FoodType::Literal => self.open_literal(content),
360      FoodType::HTTP => self.open_http(content),
361      FoodType::HTTPS => self.open_https(content),
362    };
363    self.initialize();
364    Ok(())
365  }
366
367  fn open_file(&mut self, pathname: &str) -> Result<()> {
368    if self.foodtype == FoodType::File {
369      // Perl: check readable, then check binary (non-empty), then open
370      let metadata = std::fs::metadata(pathname);
371      match &metadata {
372        Err(e) if e.kind() == io::ErrorKind::NotFound => {
373          fatal!(Mouth, MissingFile, s!("Can't find file {}", pathname));
374        },
375        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
376          Error!(
377            "I/O",
378            "unreadable",
379            s!("File {} is not readable. Ignoring.", pathname),
380            "",
381            "",
382            self.get_location()
383          );
384          return Ok(());
385        },
386        Err(e) => {
387          return Err(io::Error::new(e.kind(), e.to_string()).into());
388        },
389        Ok(meta) => {
390          // Check for binary file (non-empty and appears binary)
391          // Perl's -B heuristic: check first block for high proportion of non-text bytes
392          if meta.len() > 0
393            && let Ok(mut f) = File::open(pathname)
394          {
395            let mut buf = [0u8; 512];
396            if let Ok(n) = f.read(&mut buf)
397              && n > 0
398            {
399              let non_text = buf[..n]
400                .iter()
401                .filter(|&&b| {
402                  b == 0 || (b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' && b != 0x1b)
403                })
404                .count();
405              if non_text * 3 > n {
406                // High ratio of non-text bytes — likely binary
407                Error!(
408                  "invalid",
409                  "binary",
410                  s!("Input file {} appears to be binary. Ignoring.", pathname),
411                  "",
412                  "",
413                  self.get_location()
414                );
415                return Ok(());
416              }
417            }
418          }
419        },
420      }
421      let f = match File::open(pathname) {
422        Ok(f) => f,
423        Err(e) => {
424          Error!(
425            "I/O",
426            "open",
427            s!("Can't open {} for reading: {}", pathname, e),
428            "",
429            "",
430            self.get_location()
431          );
432          return Err(e.into());
433        },
434      };
435      let reader = BufReader::new(f);
436      self.reader = Some(reader);
437      self.buffer = VecDeque::new();
438      self.raw_buffer = VecDeque::new();
439    }
440    Ok(())
441  }
442  fn open_literal(&mut self, content: &str) { self.buffer = Mouth::split_lines(content); }
443  fn open_http(&mut self, url: &str) {
444    emit_warn(
445      "unsupported",
446      "http_input",
447      &format!("HTTP input not supported: {url}"),
448    );
449  }
450  fn open_https(&mut self, url: &str) {
451    emit_warn(
452      "unsupported",
453      "http_input",
454      &format!("HTTPS input not supported: {url}"),
455    );
456  }
457  // fn open_binding(&mut self, _content: &str) {}
458
459  fn initialize(&mut self) {
460    self.note_message = if self.notes {
461      let source = if !self.source.is_empty() {
462        &self.source
463      } else {
464        "Anonymous String"
465      };
466      let kind = if self.fordefinitions {
467        "definitions"
468      } else {
469        "content"
470      };
471      let at_note = if self.fordefinitions && !self.at_letter {
472        " w/@ other"
473      } else {
474        ""
475      };
476      Some(s!("Processing {}{} {}", kind, at_note, source))
477    } else {
478      None
479    };
480    // Perl Mouth.pm L97: ProgressSpinup($$self{note_message}) — emit
481    // `(Processing definitions <source>...` when this mouth begins reading.
482    // The matching ProgressSpindown (Mouth.pm L121) is in `finish()` below.
483    if let Some(ref msg) = self.note_message {
484      note_begin(msg);
485    }
486    // Perl: at_letter saves/restores @ catcode independently of fordefinitions.
487    // Use Scope::Global to ensure it persists across scope frame pops during file loading.
488    if self.at_letter {
489      self.saved_at_cc = lookup_catcode('@');
490      assign_catcode('@', Catcode::LETTER, Some(Scope::Global));
491    }
492    // Perl: fordefinitions saves/restores INCLUDE_COMMENTS
493    if self.fordefinitions {
494      self.saved_include_comments = match lookup_value("INCLUDE_COMMENTS") {
495        Some(Stored::Bool(x)) => Some(x),
496        _ => None,
497      };
498      assign_value("INCLUDE_COMMENTS", false, Some(Scope::Local));
499    }
500  }
501  /// Stop reading from this mouth: clear buffers and close file handle.
502  /// Called by flush_mouth (\endinput) to prevent further reading.
503  /// Does NOT restore catcodes — that's done by finish().
504  pub fn stop_reading(&mut self) {
505    self.buffer = VecDeque::new();
506    self.raw_buffer = VecDeque::new();
507    self.chars = VecDeque::new();
508    self.lineno = 0;
509    self.colno = 0;
510    self.nchars = 0;
511    self.reader.take(); // close file handle
512  }
513
514  /// Fully finish this mouth: stop reading AND restore catcodes/state.
515  /// Called by close_mouth when the mouth is popped from the stack.
516  pub fn finish(&mut self) {
517    self.stop_reading();
518    // Perl: at_letter restores @ catcode (independent of fordefinitions).
519    // Use Scope::Global to ensure it takes effect regardless of scope frame state.
520    if self.at_letter {
521      let cc = self.saved_at_cc.take().unwrap_or(Catcode::OTHER);
522      assign_catcode('@', cc, Some(Scope::Global));
523    }
524    // Perl: fordefinitions restores INCLUDE_COMMENTS
525    if let Some(sic) = self.saved_include_comments.take() {
526      assign_value("INCLUDE_COMMENTS", sic, Some(Scope::Local))
527    }
528    if self.notes
529      && let Some(ref msg) = self.note_message
530    {
531      note_end(msg);
532    }
533  }
534  // Auxiliaries
535
536  /// This is (hopefully) a platform independent way of splitting a string
537  /// into "lines" ending with CRLF, CR or LF (DOS, Mac or Unix).
538  /// Note that TeX considers newlines to be \r, ie CR, ie ^^M
539  fn split_lines(lines: &str) -> VecDeque<String> {
540    let mut lines: VecDeque<String> = LINEBREAK_REGEX.split(lines).map(str::to_owned).collect();
541    if let Some(last_line) = lines.back()
542      && last_line.is_empty()
543    {
544      lines.pop_back();
545    }
546    lines
547  }
548
549  /// Split raw bytes into lines without decoding, splitting on \r\n, \r, or \n.
550  fn split_raw_lines(bytes: &[u8]) -> VecDeque<Vec<u8>> {
551    let mut lines = VecDeque::new();
552    let mut start = 0;
553    let mut i = 0;
554    while i < bytes.len() {
555      if bytes[i] == b'\r' {
556        lines.push_back(bytes[start..i].to_vec());
557        if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
558          i += 1; // skip \n after \r
559        }
560        start = i + 1;
561      } else if bytes[i] == b'\n' {
562        lines.push_back(bytes[start..i].to_vec());
563        start = i + 1;
564      }
565      i += 1;
566    }
567    // Add remaining bytes (last line without trailing newline)
568    if start < bytes.len() {
569      lines.push_back(bytes[start..].to_vec());
570    }
571    lines
572  }
573
574  /// Decode a raw byte line using the current encoding setting.
575  /// Matches Perl's per-line decode behavior.
576  fn decode_bytes(raw_line: &[u8], location: String) -> String {
577    if let Some(ref encoding_sym) = get_input_encoding() {
578      // Probe the encoding without allocating — this fires per input
579      // line, so even a small heap alloc per call adds up on large
580      // documents. Only resolve the symbol to an owned String when we
581      // actually need it for the misdefined-encoding Info! message.
582      let is_latin1 = crate::common::arena::with(*encoding_sym, |s| {
583        s.eq_ignore_ascii_case("iso-8859-1")
584          || s.eq_ignore_ascii_case("latin1")
585          || s.eq_ignore_ascii_case("latin-1")
586      });
587      let file_str = if is_latin1 {
588        raw_line.iter().map(|&b| b as char).collect::<String>()
589      } else {
590        // Fast path for valid UTF-8 (overwhelming majority of TeX
591        // source under `inputenc[utf8]`). `str::from_utf8` validates
592        // the whole slice in a tight SIMD loop and returns a
593        // borrow on success — no `Utf8Chunks::next` iteration
594        // looking for invalid bytes. Fall back to `from_utf8_lossy`
595        // only when validation fails.
596        match str::from_utf8(raw_line) {
597          Ok(s) => s.to_string(),
598          Err(_) => String::from_utf8_lossy(raw_line).into_owned(),
599        }
600      };
601      // Replace the U+FFFD inserted by lossy decode with space. For
602      // valid-UTF-8 inputs (no FFFD), skip the replace+log scan
603      // entirely. The original logic compared `replaced.len()` to
604      // `file_str.len()` to detect FFFD presence indirectly; the
605      // explicit `contains` is cheaper and lets us avoid the
606      // unconditional `replace` walk on every input line.
607      let has_fffd = file_str.contains('\u{FFFD}');
608      if has_fffd {
609        let encoding_name = crate::common::arena::to_string(*encoding_sym);
610        Info!(
611          "misdefined",
612          &encoding_name,
613          s!("input isn't valid under encoding {}", &encoding_name),
614          "",
615          "",
616          location
617        );
618        file_str.replace('\u{FFFD}', " ")
619      } else {
620        file_str
621      }
622    } else {
623      // No encoding set — interpret as UTF-8, falling back to a Latin-1
624      // passthrough for non-UTF-8 bytes. This happens after inputenc
625      // disables PERL_INPUT_ENCODING and the remaining file lines contain
626      // high bytes.
627      decode_input_bytes(raw_line)
628    }
629  }
630
631  /// Original LaTeXML:
632  /// This is (hopefully) a correct way to split a line into "chars",
633  /// or what is probably more desired is "Grapheme clusters" (even "extended")
634  /// These are unicode characters that include any following combining chars, accents & such.
635  /// I am thinking that when we deal with unicode this may be the most correct way?
636  /// If it's not the way XeTeX does it, perhaps, it must be that ALL combining chars
637  /// have to be converted to the proper accent control sequences!
638  fn get_next_line(&mut self) -> Option<String> {
639    if self.buffer.is_empty() && !self.raw_buffer.is_empty() {
640      // Decode the next raw byte line lazily using the current encoding.
641      // This matches Perl's approach: each line is decoded with the encoding
642      // that is active at the time the line is read, allowing inputenc to
643      // change encoding mid-file.
644      if let Some(raw_line) = self.raw_buffer.pop_front() {
645        let decoded = Mouth::decode_bytes(&raw_line, self.get_location());
646        self.buffer.push_back(decoded);
647      }
648    }
649    if self.buffer.is_empty()
650      && let Some(ref mut reader) = self.reader
651    {
652      // file mouth case — read all bytes, split into raw lines, decode lazily
653      let mut file_bytes = Vec::new();
654      let _num_bytes = match reader.read_to_end(&mut file_bytes) {
655        Ok(count) => count,
656        Err(e) => {
657          let message = s!("BufReader::read_to_end returned an error: {:?}", e);
658          Warn!("mouth", "io", message, "", "", self.get_location());
659          0
660        },
661      };
662      // remove the now exhausted reader
663      self.reader.take();
664      // Split raw bytes into lines without decoding (preserving raw bytes).
665      // Each line is decoded lazily via decode_bytes() using the CURRENT encoding.
666      self.raw_buffer = Mouth::split_raw_lines(&file_bytes);
667      // Decode the first line now
668      if let Some(raw_line) = self.raw_buffer.pop_front() {
669        let decoded = Mouth::decode_bytes(&raw_line, self.get_location());
670        self.buffer.push_back(decoded);
671      }
672    }
673    self.buffer.pop_front()
674  }
675
676  /// Get the next character & it's catcode from the current line of input, even ignored chars,
677  /// handling TeX's "^^" encoding.
678  /// Note that this is the only place where catcode lookup is done (well almost),
679  /// and that it is somewhat `inlined'.
680  fn get_next_char(&mut self) -> Option<(char, Catcode)> {
681    if self.colno >= self.nchars {
682      return None;
683    };
684    let ch_opt = self.chars.get(self.colno);
685    self.colno += 1;
686    if let Some(ch) = ch_opt {
687      let mut ch = *ch;
688      let mut cc = self.catcode_of(ch);
689      // Possible convert ^^x
690      // Perl: (cc == CC_SUPER) && (colno + 1 < nchars) && (ch == chars[colno])
691      if cc == Catcode::SUPER
692        && self.colno + 1 < self.nchars
693        && Some(&ch) == self.chars.get(self.colno)
694      {
695        let c1_opt = self.chars.get(self.colno + 1);
696        let c2_opt = self.chars.get(self.colno + 2);
697        let mut two_hex = false;
698        // ^^ followed by TWO LOWERCASE Hex digits???
699        if let Some(c1) = c1_opt
700          && let Some(c2) = c2_opt
701        {
702          // Perf: avoid per-char String alloc + regex match by using
703          // direct ASCII class check. LOWERHEX_REGEX = ^[0-9a-f]$, i.e.
704          // lowercase hex digits only.
705          let is_lowerhex = |c: char| -> bool { matches!(c, '0'..='9' | 'a'..='f') };
706          if (self.colno + 2 < self.nchars) && is_lowerhex(*c1) && is_lowerhex(*c2) {
707            // TODO: Maybe Result type warranted here?
708            let hex = u8::from_str_radix(&s!("{}{}", c1, c2), 16).unwrap();
709            ch = hex as char;
710            self.splice(self.colno - 1..self.colno + 3, &[ch]);
711            self.nchars -= 3;
712            two_hex = true;
713          }
714        }
715        if !two_hex {
716          // OR ^^ followed by a SINGLE Control char type code???
717          let c = self.chars[self.colno + 1];
718          let cn = c as i16;
719
720          ch = (cn + if cn >= 64 { -64 } else { 64 }) as u8 as char;
721          self.splice(self.colno - 1..self.colno + 2, &[ch]);
722          self.nchars -= 2;
723        }
724        cc = self.catcode_of(ch);
725      }
726      Some((ch, cc))
727    } else {
728      None
729    }
730  }
731
732  /// The catcode this mouth reads `ch` with: the State's, except that a
733  /// [`Self::with_bib_data_literals`] mouth downgrades the four BibTeX-data
734  /// characters to OTHER when — and only when — they still carry their TeX
735  /// meaning.
736  fn catcode_of(&self, ch: char) -> Catcode {
737    let cc = lookup_catcode(ch).unwrap_or(Catcode::OTHER);
738    if !self.bib_data_literals {
739      return cc;
740    }
741    match (cc, ch) {
742      (Catcode::COMMENT, '%') | (Catcode::ALIGN, '&') | (Catcode::PARAM, '#') => Catcode::OTHER,
743      _ => cc,
744    }
745  }
746
747  /// Checks if there is more input to process.
748  ///
749  /// Note: we need mutability, as we may refill the internal BufReader
750  /// when performing the check.
751  pub fn has_more_input(&mut self) -> bool {
752    if !self.is_eol() || !self.buffer.is_empty() || !self.raw_buffer.is_empty() {
753      return true;
754    }
755    // Peek the underlying reader if present. A fill_buf I/O error is treated
756    // as end-of-input (return false) rather than panicking — the caller will
757    // naturally stop requesting tokens and the Mouth will be closed out.
758    match self.reader.as_mut() {
759      Some(r) => r.fill_buf().map(|buf| !buf.is_empty()).unwrap_or(false),
760      None => false,
761    }
762  }
763
764  /// Read the next token, or undef if exhausted.
765  /// Note that this also returns COMMENT tokens containing source comments,
766  /// and also locator comments (file, line# info).
767  /// LaTeXML::Core::Gullet intercepts them and passes them on at appropriate times.
768  pub fn read_token(&mut self) -> Option<Token> {
769    loop {
770      // Iterate till we find a token, or run out. (use return)
771      // ===== Get next line, if we need to.
772      if self.colno >= self.nchars {
773        self.lineno += 1;
774        self.colno = 0;
775        let line_opt = self.get_next_line();
776        // For \read, we have to return something for EOL, and handle implicit final newline
777        let read_mode = lookup_int("PRESERVE_NEWLINES") > 1;
778        let eolch = match lookup_definition(&T_CS!("\\endlinechar")).unwrap() {
779          Some(defn) => {
780            if defn.is_register() {
781              if let Some(eol) = defn.value_of(Vec::new()) {
782                let eol = eol.value_of() as i16;
783                if eol > 0 && eol <= 255 {
784                  let mch = (eol as u8) as char;
785                  Some(mch)
786                } else {
787                  None
788                }
789              } else {
790                None
791              }
792            } else {
793              None
794            }
795          },
796          _ => Some('\r'),
797        };
798        if line_opt.is_none() {
799          // Exhausted the input.
800          let eolcc = if let Some(ch) = eolch {
801            lookup_catcode(ch).unwrap_or(Catcode::OTHER)
802          } else {
803            Catcode::OTHER
804          };
805          let eoftoken = if let Some(eolch_content) = eolch {
806            if read_mode && !self.at_eof && !self.source.is_empty() {
807              if eolcc == Catcode::EOL {
808                Some(T_CS!("\\par"))
809              } else {
810                Some(CharToken!(eolch_content, eolcc))
811              }
812            } else {
813              None
814            }
815          } else {
816            None
817          };
818          self.at_eof = true;
819          self.chars = VecDeque::new();
820          self.nchars = 0;
821          return eoftoken;
822        }
823        // Remove trailing spaces from external sources
824        let mut line = line_opt.unwrap();
825        if !self.source.is_empty() && line.ends_with(' ') {
826          line = TRAILING_SPACE_CHARS.replace(&line, "").to_string();
827        }
828        // Then append the appropriate \endlinechar, or "\r";
829        if let Some(ch) = eolch {
830          line.push(ch);
831        }
832
833        self.chars = line.chars().collect::<VecDeque<char>>();
834        self.nchars = self.chars.len();
835        // In state N, skip leading spaces & ignored, possibly decoding (trailing space removed
836        // above)
837        while let Some((_ch, cc)) = self.get_next_char() {
838          match cc {
839            Catcode::SPACE | Catcode::IGNORE => {},
840            Catcode::EOL => {
841              // Eolch already? empty line!
842              self.colno = self.nchars; // ignore rest of line.
843              return Some(T_CS!("\\par"));
844            },
845            _ => break,
846          }
847        }
848        if self.nchars == 0 || self.colno > self.nchars {
849          // Past end of line?
850          // If upcoming line is empty, and there is no recognizable EOL, fake one
851          if read_mode && eolch != Some('\r') {
852            return Some(T_MARKER!("EOL"));
853          }
854        } else {
855          // Back up over peeked char
856          self.colno -= 1;
857        }
858        // Sneak a comment out, every so often.
859        if self.lineno.is_multiple_of(READLINE_PROGRESS_QUANTUM) && lookup_bool("INCLUDE_COMMENTS")
860        {
861          // Perl T_COMMENT prepends '%' (Token.pm L81)
862          return Some(T_COMMENT!(s!(
863            "%**** {} Line {} ****",
864            &self.shortsource,
865            &self.lineno.to_string()
866          )));
867        }
868      }
869      // In state::S, skip spaces
870      if self.skipping_spaces {
871        let mut cc = None;
872        // This is very awkward as a loop,
873        //  but I had to port the Perl logic without going crazy...
874        // tokenizer/verb.tex depends on it.
875        while let Some((_, ncc)) = self.get_next_char() {
876          cc = Some(ncc);
877          if ncc != Catcode::SPACE {
878            break;
879          }
880        }
881        if self.colno <= self.nchars && cc.is_some() && cc != Some(Catcode::SPACE) {
882          self.colno -= 1;
883        }
884        if cc == Some(Catcode::EOL) {
885          // If we've got an EOL
886          self.get_next_char();
887          if self.colno < self.nchars {
888            self.colno -= 1;
889          }
890        }
891        self.skipping_spaces = false;
892      }
893      // ==== Extract next token from line.
894      // §1 (docs/performance/SOURCE_PROVENANCE.md): record the token's source start now —
895      // after all inter-token skips (line fetch, leading / skipping spaces) and
896      // before `get_next_char` advances past its first char. `colno` is 0-indexed
897      // here; the +1 to 1-indexed columns happens in `get_locator`.
898      self.last_token_start = (self.lineno, self.colno);
899      if let Some((ch, cc)) = self.get_next_char() {
900        #[cfg(not(feature = "token-locators"))]
901        if let Some(token) = Mouth::dispatch_char(self, ch, cc) {
902          return Some(token);
903        } // Else, repeat till we get something or run out.
904        // token-locators: stamp the token with an origin handle into the side
905        // arena, using `last_token_start` (the token's first char — captured
906        // above, before `dispatch_char` reads the rest, e.g. a CS name). This is
907        // what survives expansion to digestion (Experiments 1–3 showed the mouth
908        // position at digest time cannot recover it). See SOURCE_PROVENANCE §3.1.1.
909        #[cfg(feature = "token-locators")]
910        if let Some(mut token) = Mouth::dispatch_char(self, ch, cc) {
911          let (line, col0) = self.last_token_start;
912          token.loc =
913            crate::token::push_token_origin(self.source_sym, line as u32, (col0 + 1) as u32);
914          return Some(token);
915        } // Else, repeat till we get something or run out.
916      }
917    }
918  }
919
920  //**********************************************************************
921  /// Read all tokens until a token equal to $until (if given), or until exhausted.
922  /// Returns an empty Tokens list, if there is no input
923  pub fn read_tokens(&mut self) -> Tokens {
924    // Pre-size to skip the early doubling reallocations of the per-token push
925    // loop below (a `grow_one` site in the allocation profile); 16 covers a
926    // typical line/group in one allocation.
927    let mut tokens = Vec::with_capacity(16);
928    while let Some(token) = self.read_token() {
929      tokens.push(token);
930    }
931    while let Some(Token { code: Catcode::SPACE, .. }) = tokens.last() {
932      // Remove trailing space
933      tokens.pop();
934    }
935    Tokens::new(tokens)
936  }
937
938  //**********************************************************************
939  // Read a raw lines; there are so many variants of how it should end,
940  // that the Mouth API is left as simple as possible.
941  // Alas: $noread true means NOT to read a new line, but only return
942  // the remainder of the current line, if any. This is useful when combining
943  // with previously peeked tokens from the Gullet.
944  pub fn read_raw_line(&mut self, noread: bool) -> Option<String> {
945    let mut line = String::new();
946    if self.colno < self.nchars {
947      line = self.chars.iter().skip(self.colno).collect();
948      // Strip the final carriage return, if it has been added back (Perl: s/\r$//s)
949      if line.ends_with('\r') {
950        line.pop();
951      }
952      self.colno = self.nchars;
953    } else if !noread {
954      match self.get_next_line() {
955        None => {
956          // We've exhausted this mouth
957          self.at_eof = true;
958          self.chars = VecDeque::new();
959          self.nchars = 0;
960          self.colno = 0;
961          return None;
962        },
963        Some(next_line) => {
964          // Strip trailing spaces (Perl: s/ *$//s)
965          line = next_line.trim_end_matches(' ').to_string();
966          self.lineno += 1;
967          self.chars = line.chars().collect();
968          self.nchars = self.chars.len();
969          self.colno = self.nchars;
970        },
971      }
972    }
973    Some(line)
974  }
975
976  fn dispatch_char(&mut self, ch: char, cc: Catcode) -> Option<Token> {
977    // Possibly want to think about caching (common) letters, etc to keep from
978    // creating tokens like crazy... or making them more compact... or ???
979    use crate::token::Catcode::*;
980    match cc {
981      ESCAPE => self.handle_escape(), // T_ESCAPE
982      BEGIN => {
983        if ch == '{' {
984          Some(T_BEGIN!())
985        } else {
986          Some(CharToken!(ch, BEGIN))
987        }
988      },
989      END => {
990        if ch == '}' {
991          Some(T_END!())
992        } else {
993          Some(CharToken!(ch, END))
994        }
995      },
996      MATH => {
997        if ch == '$' {
998          Some(T_MATH!())
999        } else {
1000          Some(CharToken!(ch, MATH))
1001        }
1002      },
1003      ALIGN => {
1004        if ch == '&' {
1005          Some(T_ALIGN!())
1006        } else {
1007          Some(CharToken!(ch, ALIGN))
1008        }
1009      },
1010      EOL => Some(self.handle_end_of_line()),
1011      PARAM => {
1012        if ch == '#' {
1013          Some(T_PARAM!())
1014        } else {
1015          Some(CharToken!(ch, PARAM))
1016        }
1017      }, // T_PARAM
1018      SUPER => {
1019        if ch == '^' {
1020          Some(T_SUPER!())
1021        } else {
1022          Some(CharToken!(ch, SUPER))
1023        }
1024      }, // T_SUPER
1025      SUB => {
1026        if ch == '_' {
1027          Some(T_SUB!())
1028        } else {
1029          Some(CharToken!(ch, SUB))
1030        }
1031      }, // T_SUB
1032      SPACE => self.handle_space(),
1033      LETTER => Some(CharToken!(ch, LETTER)),
1034      OTHER => Some(CharToken!(ch, OTHER)),
1035      ACTIVE => Some(T_ACTIVE!(ch)),
1036      COMMENT => self.handle_comment(),
1037      INVALID => Some(CharToken!(ch, OTHER)), // T_INVALID (we could get unicode!)
1038      _ => None,                              // IGNORE, others
1039    }
1040  }
1041
1042  fn handle_end_of_line(&mut self) -> Token {
1043    self.colno = self.nchars; // Ignore any remaining characters after EOL
1044    if lookup_int("PRESERVE_NEWLINES") != 0 {
1045      Token!("\n", Catcode::SPACE)
1046    } else {
1047      T_SPACE!()
1048    }
1049  }
1050
1051  fn handle_space(&mut self) -> Option<Token> {
1052    // Skip any following spaces!
1053    while let Some((_ch, cc)) = self.get_next_char() {
1054      if (cc != Catcode::SPACE) && (cc != Catcode::EOL) {
1055        // backup at nonspace/eol
1056        if self.colno <= self.nchars {
1057          self.colno -= 1;
1058        }
1059        break;
1060      }
1061    }
1062    Some(T_SPACE!())
1063  }
1064
1065  fn handle_comment(&mut self) -> Option<Token> {
1066    let n = self.colno;
1067    self.colno = self.nchars;
1068    let mut comment = String::new();
1069    for c in self.chars.iter().skip(n).take(self.nchars - n) {
1070      comment.push(*c);
1071    }
1072    let trimmed_comment = comment.trim();
1073    if !trimmed_comment.is_empty() && lookup_bool("INCLUDE_COMMENTS") {
1074      // Perl T_COMMENT prepends '%' to the comment text (Token.pm L81)
1075      Some(T_COMMENT!(s!("%{}", trimmed_comment)))
1076    } else if lookup_int("PRESERVE_NEWLINES") > 1 {
1077      Some(T_MARKER!("EOL")) // Required EOL during \read
1078    } else {
1079      None
1080    }
1081  }
1082
1083  //**********************************************************************
1084  // See The TeXBook, Chapter 8, The Characters You Type, pp.46--47.
1085  //**********************************************************************
1086
1087  /// Read control sequence
1088  fn handle_escape(&mut self) -> Option<Token> {
1089    // NOTE: We're using control sequences WITH the \ prepended!!!
1090    if let Some((ch, mut cc)) = self.get_next_char() {
1091      // Knuth, p.46 says that Newlines are converted to spaces,
1092      // Bit I believe that he does NOT mean within control sequences
1093      let mut cs = s!("\\{}", ch);
1094      if cc == Catcode::LETTER {
1095        // For letter, read more letters for csname.
1096        while let Some((nch, ncc)) = self.get_next_char() {
1097          cc = ncc;
1098          if ncc == Catcode::LETTER {
1099            cs.push(nch);
1100          } else {
1101            break;
1102          }
1103        }
1104        // We WILL skip spaces, but not till next token is read (in case catcode changes!!!!)
1105        self.skipping_spaces = true;
1106        if cc != Catcode::LETTER {
1107          self.colno -= 1;
1108        }
1109      }
1110      Some(T_CS!(cs))
1111    } else {
1112      None
1113    }
1114  }
1115
1116  /// TODO: Can we use/build a generic that does this reliably for VecDeque
1117  fn splice<R>(&mut self, range: R, with: &[char])
1118  where R: RangeBounds<usize> {
1119    let mut v: Vec<char> = self.chars.drain(..).collect();
1120    v.splice(range, with.iter().cloned());
1121    self.chars = v.into_iter().collect();
1122  }
1123
1124  /// Checks if Mouth read is at the end of a line.
1125  ///
1126  /// Careful:
1127  /// used BOTH for flushing input for `\endinput`
1128  /// and for detecting line end for `\read`
1129  pub fn is_eol(&mut self) -> bool {
1130    let savecolno = self.colno;
1131    // We have to peek past any ignored tokens & also spaces, if skipping
1132    let mut cc = None;
1133    while let Some((_, ncc)) = self.get_next_char() {
1134      if ncc != Catcode::IGNORE && (!self.skipping_spaces || ncc != Catcode::SPACE) {
1135        cc = Some(ncc);
1136        break;
1137      }
1138    }
1139    if self.colno <= self.nchars && cc.is_some() {
1140      // Back-up if too far.
1141      self.colno -= 1;
1142    }
1143    // If skipping spaces (really, reading for input (\endinput) ?), jump to end of EOL or comments
1144    if self.skipping_spaces && (cc == Some(Catcode::EOL) || cc == Some(Catcode::COMMENT)) {
1145      // If we've got an EOL | COMMENT
1146      self.colno = self.nchars
1147    }
1148    let eol = self.colno >= self.nchars;
1149    self.colno = savecolno;
1150    eol
1151  }
1152
1153  pub fn at_eof(&self) -> bool { self.at_eof }
1154
1155  /// §1 accurate-start locator (docs/performance/SOURCE_PROVENANCE.md): `from` = the captured
1156  /// start of the most recently *begun* token (`last_token_start`), `to` = the
1157  /// mouth's current position. Unlike `get_locator`, whose `from` is the
1158  /// eating-disorder heuristic (line start vs current col), this `from` is exact
1159  /// for the token currently being processed — the basis for accurate
1160  /// construct-start ranges under `--source-map`. `lineno` is already 1-indexed
1161  /// (it counts from 1 after the first line fetch); `colno` is 0-indexed, +1 to
1162  /// 1-indexed columns, matching `get_locator`.
1163  pub fn get_locator_from_start(&self) -> Locator {
1164    let (from_line, from_col0) = self.last_token_start;
1165    Locator::from_sym(
1166      self.source_sym,
1167      from_line as u32,
1168      (from_col0 + 1) as u32,
1169      self.lineno as u32,
1170      (self.colno + 1) as u32,
1171    )
1172  }
1173
1174  pub fn get_location(&self) -> String {
1175    let loc = self.get_locator().unwrap_or_default();
1176    s!("at {}", loc)
1177  }
1178}
1179
1180/// Tokenize a string under the **standard** catcode table — Perl
1181/// `Package.pm:Tokenize` L1019-1023.
1182///
1183/// "Standard" is the document-level table: `@` is an ordinary letter-less
1184/// character, so this is how user-facing text should be read. The current
1185/// state's catcodes are deliberately NOT consulted; the table is swapped in for
1186/// the duration and restored afterwards, exactly as Perl's `local $STATE =
1187/// $STD_CATTABLE` does, so a document that has been playing with catcodes
1188/// cannot change what a binding's own string means.
1189///
1190/// See [`tokenize_internal`] for the `.sty`-style table that treats `@` as a
1191/// letter.
1192///
1193/// The argument is an `impl Into<`[`TeXString`]`>`, not a `&str`: a string
1194/// literal converts implicitly, but a `String` — the shape a control-word-welding
1195/// `Tokens::to_string()` arrives in — must declare itself via
1196/// [`Tokens::untex_string`] or [`TeXString::assembled`]. See the [`TeXString`]
1197/// docs for why.
1198pub fn tokenize(text: impl Into<TeXString>) -> Tokens {
1199  let text = text.into();
1200  // special case! empty input is empty Tokens
1201  if text.is_empty() {
1202    return NO_TOKENS;
1203  }
1204  use_std_state();
1205  let result = Mouth::new(text.as_str(), None).unwrap().read_tokens();
1206  use_main_state();
1207  result
1208}
1209/// Tokenize a string under the standard catcode table, reading `% & #` as
1210/// ordinary characters rather than comment, alignment tab and parameter.
1211///
1212/// For text that came out of the BibTeX lexer, which has none of those
1213/// constructs, so all three are data — treatment 1 of `OXIDIZED_DESIGN #74`,
1214/// see [`Mouth::with_bib_data_literals`] (including why `_` is not in the set).
1215/// Plain [`tokenize`] would let a `%` comment out the rest of the string, which
1216/// for a `.bib` field means losing its closing brace and leaving whatever it
1217/// opened unclosed, and would make the `&` in "Taylor & Francis" a stray
1218/// alignment tab.
1219///
1220/// This exists because the handlers that re-read a raw field — `\bib@@title`
1221/// recasing, name splitting, date/pages assembly — build their tokens from the
1222/// stored string and never pass through the per-entry mouth.
1223///
1224/// Takes an `impl Into<`[`TeXString`]`>` for the same reason as [`tokenize`] —
1225/// and it is the sink that most needs it: the bibliography is where the
1226/// control-word weld has surfaced three times (PR #399, PR #400, issue 410).
1227pub fn tokenize_bib_literal(text: impl Into<TeXString>) -> Tokens {
1228  let text = text.into();
1229  // special case! empty input is empty Tokens
1230  if text.is_empty() {
1231    return NO_TOKENS;
1232  }
1233  use_std_state();
1234  let result = Mouth::new(text.as_str(), None)
1235    .unwrap()
1236    .with_bib_data_literals()
1237    .read_tokens();
1238  use_main_state();
1239  result
1240}
1241
1242/// Tokenize a string under the **style-file** catcode table — Perl
1243/// `Package.pm:TokenizeInternal` L1026-1030.
1244///
1245/// Same swap-and-restore discipline as [`tokenize`], but with `@` a letter, so
1246/// internal control sequences (`\@ifnextchar`, `\@currentlabel`, …) tokenize as
1247/// single names. This is the right choice for a macro body a binding writes
1248/// itself, and the wrong one for text that came from the document.
1249///
1250/// Takes an `impl Into<`[`TeXString`]`>` for the same reason as [`tokenize`].
1251pub fn tokenize_internal(text: impl Into<TeXString>) -> Tokens {
1252  let text = text.into();
1253  // special case! empty input is empty Tokens
1254  if text.is_empty() {
1255    return NO_TOKENS;
1256  }
1257  use_sty_state();
1258  let result = Mouth::new(text.as_str(), None).unwrap().read_tokens();
1259  use_main_state();
1260  result
1261}
1262
1263#[cfg(test)]
1264mod newline_tests {
1265  use super::*;
1266
1267  /// CRLF-input regression guard (WINDOWS_COMPATIBILITY_PLAN risk #5): a
1268  /// document saved with Windows line endings must tokenize identically to
1269  /// its LF twin — split_raw_lines implements TeX's universal end-of-line
1270  /// (\r\n, \r, and \n all terminate a line, and the terminator itself
1271  /// never reaches the catcode machinery).
1272  #[test]
1273  fn split_raw_lines_universal_newlines() {
1274    let lf = Mouth::split_raw_lines(b"a\nb\nc");
1275    let crlf = Mouth::split_raw_lines(b"a\r\nb\r\nc");
1276    let cr = Mouth::split_raw_lines(b"a\rb\rc");
1277    assert_eq!(lf, crlf, "CRLF must split identically to LF");
1278    assert_eq!(lf, cr, "bare CR must split identically to LF");
1279    assert_eq!(lf.len(), 3);
1280    assert_eq!(lf[0], b"a");
1281    // A lone \r inside the terminator pair is consumed, not leaked into
1282    // the following line.
1283    assert!(
1284      crlf.iter().all(|line| !line.contains(&b'\r')),
1285      "no line may retain a raw CR byte"
1286    );
1287  }
1288}