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}