1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{ByteSpan, FormatParser, Line, SpannedRegion, flush_prose_spanned, iter_lines};
5use crate::sentence::unicode::latex_verb_span_end_with;
6
7static NON_PROSE_ENVS: &[&str] = &[
9 "equation",
10 "equation*",
11 "align",
12 "align*",
13 "gather",
14 "gather*",
15 "multline",
16 "multline*",
17 "eqnarray",
18 "eqnarray*",
19 "figure",
20 "figure*",
21 "table",
22 "table*",
23 "tabular",
24 "tabular*",
25 "lstlisting",
26 "verbatim",
27 "minted",
28 "tikzpicture",
29 "array",
30 "matrix",
31 "pmatrix",
32 "bmatrix",
33];
34
35static MINTED_LANG_RE: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"\\begin\{minted\}\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}").unwrap());
38
39static LSTLISTING_LANG_RE: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r"\\begin\{lstlisting\}\s*\[[^\]]*language\s*=\s*([A-Za-z0-9_+.\-]+)").unwrap()
42});
43
44fn is_builtin_code_env(name: &str) -> bool {
46 matches!(name, "minted" | "lstlisting" | "verbatim")
47}
48
49static DISPLAY_MATH_OPEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\\\[").unwrap());
50
51static DISPLAY_MATH_CLOSE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\]\s*$").unwrap());
52
53static SECTION_CMD_RE: LazyLock<Regex> = LazyLock::new(|| {
56 Regex::new(
57 r"^(\s*\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{)([^}]*)(\}.*)$",
58 )
59 .unwrap()
60});
61
62#[derive(Debug, Default, Clone)]
63pub struct LatexParser {
64 extra_verbatim_envs: Vec<String>,
65 extra_structure_envs: Vec<String>,
66 extra_verbatim_commands: Vec<String>,
67}
68
69impl LatexParser {
70 pub(crate) fn from_config(config: Option<&crate::FormatConfig>) -> Self {
71 match config {
72 Some(c) => Self {
73 extra_verbatim_envs: c.latex_verbatim_envs.clone(),
74 extra_structure_envs: c.latex_structure_envs.clone(),
75 extra_verbatim_commands: c.latex_verbatim_commands.clone(),
76 },
77 None => Self::default(),
78 }
79 }
80
81 fn is_comment(line: &str) -> bool {
82 line.trim_start().starts_with('%')
83 }
84
85 fn unescaped_percent(&self, line: &str) -> Option<usize> {
88 unescaped_percent_with(line, &self.extra_verbatim_commands)
89 }
90
91 fn is_code_env(&self, name: &str) -> bool {
92 is_builtin_code_env(name) || self.extra_verbatim_envs.iter().any(|e| e == name)
93 }
94
95 fn is_non_prose_env(&self, name: &str) -> bool {
96 NON_PROSE_ENVS.contains(&name)
97 || self.extra_structure_envs.iter().any(|e| e == name)
98 || self.is_code_env(name)
99 }
100}
101
102fn unescaped_percent_with(line: &str, extra_cmds: &[String]) -> Option<usize> {
103 let bytes = line.as_bytes();
104 let mut i = 0;
105 while i < bytes.len() {
106 if bytes[i] == b'\\' {
107 if let Some(end) = latex_verb_span_end_with(line, i, extra_cmds) {
108 i = end;
109 continue;
110 }
111 if i + 1 < bytes.len() {
112 i += 2;
113 continue;
114 }
115 }
116 if bytes[i] == b'%' {
117 return Some(i);
118 }
119 i += 1;
120 }
121 None
122}
123
124#[derive(Debug, Clone)]
126struct EnvHit {
127 start: usize,
128 end: usize,
129 is_begin: bool,
130 name: String,
131}
132
133fn is_env_name(name: &str) -> bool {
134 let core = name.strip_suffix('*').unwrap_or(name);
135 !core.is_empty() && core.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
136}
137
138fn rest_line(line: Line<'_>, rel: usize) -> Line<'_> {
139 Line {
140 start: line.start + rel,
141 end: line.end,
142 text: &line.text[rel..],
143 }
144}
145
146fn thru_eol_if_blank_rest(line: Line<'_>, rel: usize) -> usize {
148 if line.text[rel..].trim().is_empty() {
149 line.end
150 } else {
151 line.start + rel
152 }
153}
154
155fn find_env_at(line: &str, from: usize, extra_cmds: &[String]) -> Option<EnvHit> {
156 let bytes = line.as_bytes();
157 let mut i = from;
158 let stop = unescaped_percent_with(line, extra_cmds).unwrap_or(line.len());
159 while i < stop {
160 if bytes[i] == b'\\' {
161 if let Some(end) = latex_verb_span_end_with(line, i, extra_cmds) {
162 i = end;
163 continue;
164 }
165 let rest = &line[i..];
166 let (is_begin, prefix_len) = if rest.starts_with("\\begin{") {
167 (true, "\\begin{".len())
168 } else if rest.starts_with("\\end{") {
169 (false, "\\end{".len())
170 } else if i + 1 < bytes.len() {
171 i += 2;
172 continue;
173 } else {
174 break;
175 };
176 let name_start = i + prefix_len;
177 if let Some(rel) = line[name_start..stop].find('}') {
178 let name = &line[name_start..name_start + rel];
179 if is_env_name(name) {
180 let mut end = name_start + rel + 1;
181 if is_begin {
182 let mut j = end;
183 while j < stop && matches!(line.as_bytes()[j], b' ' | b'\t') {
184 j += 1;
185 }
186 if let Some(br) = skip_optional_brackets(line, j, stop) {
187 end = br;
188 }
189 }
190 return Some(EnvHit {
191 start: i,
192 end,
193 is_begin,
194 name: name.to_string(),
195 });
196 }
197 }
198 i += 1;
199 continue;
200 }
201 i += 1;
202 }
203 None
204}
205
206fn skip_optional_brackets(line: &str, open_at: usize, stop: usize) -> Option<usize> {
207 let bytes = line.as_bytes();
208 if bytes.get(open_at) != Some(&b'[') {
209 return None;
210 }
211 let mut depth = 0;
212 let mut i = open_at;
213 while i < stop {
214 match bytes[i] {
215 b'[' => depth += 1,
216 b']' => {
217 depth -= 1;
218 if depth == 0 {
219 return Some(i + 1);
220 }
221 }
222 _ => {}
223 }
224 i += 1;
225 }
226 None
227}
228
229fn find_matching_end(
230 line: &str,
231 from: usize,
232 name: &str,
233 mut depth: usize,
234 extra_cmds: &[String],
235) -> Option<usize> {
236 let mut i = from;
237 while let Some(hit) = find_env_at(line, i, extra_cmds) {
238 if hit.name != name {
239 i = hit.end;
240 continue;
241 }
242 if hit.is_begin {
243 depth += 1;
244 i = hit.end;
245 } else {
246 depth -= 1;
247 if depth == 0 {
248 return Some(hit.end);
249 }
250 i = hit.end;
251 }
252 }
253 None
254}
255
256fn find_raw_env_at(line: &str, from: usize) -> Option<EnvHit> {
259 let bytes = line.as_bytes();
260 let mut i = from;
261 while i < bytes.len() {
262 if bytes[i] == b'\\' {
263 let rest = &line[i..];
264 let (is_begin, prefix_len) = if rest.starts_with("\\begin{") {
265 (true, "\\begin{".len())
266 } else if rest.starts_with("\\end{") {
267 (false, "\\end{".len())
268 } else {
269 i += 1;
270 continue;
271 };
272 let name_start = i + prefix_len;
273 if let Some(rel) = line[name_start..].find('}') {
274 let name = &line[name_start..name_start + rel];
275 if is_env_name(name) {
276 return Some(EnvHit {
277 start: i,
278 end: name_start + rel + 1,
279 is_begin,
280 name: name.to_string(),
281 });
282 }
283 }
284 }
285 i += 1;
286 }
287 None
288}
289
290fn find_matching_raw_end(line: &str, from: usize, name: &str, mut depth: usize) -> Option<usize> {
291 let mut i = from;
292 while let Some(hit) = find_raw_env_at(line, i) {
293 if hit.name != name {
294 i = hit.end;
295 continue;
296 }
297 if hit.is_begin {
298 depth += 1;
299 i = hit.end;
300 } else {
301 depth -= 1;
302 if depth == 0 {
303 return Some(hit.end);
304 }
305 i = hit.end;
306 }
307 }
308 None
309}
310
311struct ParseState<'a> {
312 input: &'a str,
313 parser: &'a LatexParser,
314 regions: Vec<SpannedRegion>,
315 current_prose: String,
316 prose_span: Option<ByteSpan>,
317 in_non_prose_env: Option<String>,
318 non_prose_depth: usize,
319 in_code_env: Option<String>,
320 code_depth: usize,
321 code_lang: Option<String>,
322 code_header: ByteSpan,
323 code_body_start: usize,
324 in_display_math: bool,
325 nospace_join: bool,
326}
327
328impl<'a> ParseState<'a> {
329 fn flush(&mut self) {
330 flush_prose_spanned(
331 &mut self.current_prose,
332 &mut self.prose_span,
333 &mut self.regions,
334 );
335 }
336
337 fn push_structure(&mut self, span: ByteSpan) {
338 self.flush();
339 if span.is_empty() {
340 return;
341 }
342 self.regions
343 .push(SpannedRegion::structure(self.input, span));
344 }
345
346 fn extend_prose_to(&mut self, end: usize) {
347 if let Some(s) = &mut self.prose_span {
348 if end > s.end {
349 s.end = end;
350 }
351 }
352 }
353
354 fn append_prose_slice(&mut self, abs_start: usize, piece: &str) {
355 let trimmed = piece.trim();
356 if trimmed.is_empty() {
357 return;
358 }
359 let lead = piece.len() - piece.trim_start().len();
360 let content_start = abs_start + lead;
361 let content_end = content_start + trimmed.len();
362 if !self.current_prose.is_empty() && !self.nospace_join {
363 self.current_prose.push(' ');
364 }
365 self.current_prose.push_str(trimmed);
366 match &mut self.prose_span {
367 None => self.prose_span = Some(ByteSpan::new(content_start, content_end)),
368 Some(s) => s.end = content_end,
369 }
370 self.nospace_join = false;
371 }
372
373 fn enter_code(&mut self, env_name: &str, line: Line<'_>, hit_start: usize) {
374 self.flush();
375 let header_src = &line.text[hit_start..];
376 self.code_lang = if env_name == "minted" {
377 MINTED_LANG_RE
378 .captures(header_src)
379 .map(|c| c.get(1).unwrap().as_str().to_string())
380 } else if env_name == "lstlisting" {
381 LSTLISTING_LANG_RE
382 .captures(header_src)
383 .map(|c| c.get(1).unwrap().as_str().to_string())
384 } else {
385 None
386 };
387 self.code_header = ByteSpan::new(line.start + hit_start, line.end);
388 self.code_body_start = line.end;
389 self.in_code_env = Some(env_name.to_string());
390 self.code_depth = 1;
391 }
392
393 fn consume_body_line(&mut self, line: Line<'_>) {
394 if self.in_non_prose_env.is_some() {
395 self.consume_non_prose_line(line);
396 return;
397 }
398
399 if self.in_display_math {
400 self.flush();
401 if DISPLAY_MATH_CLOSE.is_match(line.text) {
402 self.in_display_math = false;
403 }
404 self.regions
405 .push(SpannedRegion::structure(self.input, line.span()));
406 return;
407 }
408
409 if line.text.trim().is_empty() {
410 self.flush();
411 self.nospace_join = false;
412 self.regions
413 .push(SpannedRegion::blank(self.input, line.span()));
414 return;
415 }
416
417 if LatexParser::is_comment(line.text) {
418 self.flush();
419 self.nospace_join = false;
420 self.regions
421 .push(SpannedRegion::structure(self.input, line.span()));
422 return;
423 }
424
425 let pct = self.parser.unescaped_percent(line.text);
426 let code = match pct {
427 Some(idx) => &line.text[..idx],
428 None => line.text,
429 };
430
431 if !code.trim().is_empty() {
432 let line_done = self.consume_code_span(code, line);
433 if line_done || self.in_code_env.is_some() || self.in_non_prose_env.is_some() {
434 return;
435 }
436 }
437
438 if let Some(idx) = pct {
439 self.nospace_join = true;
440 let comment = &line.text[idx..];
441 if comment.trim() != "%" {
442 self.flush();
443 self.regions.push(SpannedRegion::structure(
444 self.input,
445 ByteSpan::new(line.start + idx, line.end),
446 ));
447 } else {
448 self.extend_prose_to(line.end);
449 }
450 } else if self.in_code_env.is_none() && self.in_non_prose_env.is_none() {
451 self.extend_prose_to(line.end);
452 self.nospace_join = false;
453 }
454 }
455
456 fn consume_non_prose_line(&mut self, line: Line<'_>) {
457 let name = self
458 .in_non_prose_env
459 .as_deref()
460 .expect("consume_non_prose_line only when inside")
461 .to_string();
462 let mut i = 0;
463 while let Some(hit) = find_env_at(line.text, i, &self.parser.extra_verbatim_commands) {
464 if hit.name != name {
465 i = hit.end;
466 continue;
467 }
468 if hit.is_begin {
469 self.non_prose_depth += 1;
470 i = hit.end;
471 } else {
472 self.non_prose_depth -= 1;
473 if self.non_prose_depth == 0 {
474 let end = thru_eol_if_blank_rest(line, hit.end);
475 self.regions.push(SpannedRegion::structure(
476 self.input,
477 ByteSpan::new(line.start, end),
478 ));
479 self.in_non_prose_env = None;
480 if !line.text[hit.end..].trim().is_empty() {
481 self.consume_body_line(rest_line(line, hit.end));
482 }
483 return;
484 }
485 i = hit.end;
486 }
487 }
488 self.regions
489 .push(SpannedRegion::structure(self.input, line.span()));
490 }
491
492 fn consume_code_env_line(&mut self, line: Line<'_>) {
493 let name = self
494 .in_code_env
495 .as_deref()
496 .expect("consume_code_env_line only when inside")
497 .to_string();
498 let mut i = 0;
499 while let Some(hit) = find_raw_env_at(line.text, i) {
500 if hit.name != name {
501 i = hit.end;
502 continue;
503 }
504 if hit.is_begin {
505 self.code_depth += 1;
506 i = hit.end;
507 } else {
508 self.code_depth -= 1;
509 if self.code_depth == 0 {
510 let footer_end = thru_eol_if_blank_rest(line, hit.end);
511 let footer = ByteSpan::new(line.start + hit.start, footer_end);
512 self.in_code_env = None;
513 self.regions.push(SpannedRegion::code(
514 self.input,
515 self.code_lang.take(),
516 self.code_header,
517 ByteSpan::new(self.code_body_start, line.start + hit.start),
518 footer,
519 ));
520 if !line.text[hit.end..].trim().is_empty() {
521 self.consume_body_line(rest_line(line, hit.end));
522 }
523 return;
524 }
525 i = hit.end;
526 }
527 }
528 }
529
530 fn consume_code_span(&mut self, code: &str, line: Line<'_>) -> bool {
532 let mut i = 0;
533 while i < code.len() {
534 if let Some(hit) = find_env_at(code, i, &self.parser.extra_verbatim_commands) {
535 self.append_prose_slice(line.start + i, &code[i..hit.start]);
536 if hit.is_begin && self.parser.is_code_env(&hit.name) {
537 self.flush();
538 if let Some(end_at) = find_matching_raw_end(line.text, hit.end, &hit.name, 1) {
539 let header = ByteSpan::new(line.start + hit.start, line.start + end_at);
540 let empty = ByteSpan::new(line.start + end_at, line.start + end_at);
541 self.regions
542 .push(SpannedRegion::code(self.input, None, header, empty, empty));
543 if !line.text[end_at..].trim().is_empty() {
544 self.consume_body_line(rest_line(line, end_at));
545 }
546 return true;
547 }
548 self.enter_code(&hit.name, line, hit.start);
549 return true;
550 }
551 if hit.is_begin && self.parser.is_non_prose_env(&hit.name) {
552 self.flush();
553 if let Some(end_at) = find_matching_end(
554 code,
555 hit.end,
556 &hit.name,
557 1,
558 &self.parser.extra_verbatim_commands,
559 ) {
560 let end = if code[end_at..].trim().is_empty() {
561 thru_eol_if_blank_rest(line, end_at)
562 } else {
563 line.start + end_at
564 };
565 self.regions.push(SpannedRegion::structure(
566 self.input,
567 ByteSpan::new(line.start + hit.start, end),
568 ));
569 i = end_at;
570 continue;
571 }
572 self.in_non_prose_env = Some(hit.name);
573 self.non_prose_depth = 1;
574 self.regions.push(SpannedRegion::structure(
575 self.input,
576 ByteSpan::new(line.start + hit.start, line.end),
577 ));
578 return true;
579 }
580 let cmd_end = thru_eol_if_blank_rest(line, hit.end);
581 self.push_structure(ByteSpan::new(line.start + hit.start, cmd_end));
582 i = hit.end;
583 continue;
584 }
585
586 let rest = &code[i..];
587 if SECTION_CMD_RE.is_match(rest) {
588 self.push_structure(ByteSpan::new(line.start + i, line.end));
589 return false;
590 }
591 if DISPLAY_MATH_OPEN.is_match(rest) {
592 self.flush();
593 if !DISPLAY_MATH_CLOSE.is_match(rest) {
594 self.in_display_math = true;
595 }
596 self.regions.push(SpannedRegion::structure(
597 self.input,
598 ByteSpan::new(line.start + i, line.end),
599 ));
600 return false;
601 }
602 self.append_prose_slice(line.start + i, rest);
603 return false;
604 }
605 false
606 }
607}
608
609impl FormatParser for LatexParser {
610 fn parse_full(&self, input: &str) -> Vec<SpannedRegion> {
611 let mut state = ParseState {
612 input,
613 parser: self,
614 regions: Vec::new(),
615 current_prose: String::new(),
616 prose_span: None,
617 in_non_prose_env: None,
618 non_prose_depth: 0,
619 in_code_env: None,
620 code_depth: 0,
621 code_lang: None,
622 code_header: ByteSpan::default(),
623 code_body_start: 0,
624 in_display_math: false,
625 nospace_join: false,
626 };
627 let mut in_preamble = true;
628 let mut pragma_off = false;
629
630 for line in iter_lines(input) {
631 if state.in_code_env.is_none() {
634 if let Some(on) = super::check_pragma(line.text) {
635 state.flush();
636 pragma_off = !on;
637 state
638 .regions
639 .push(SpannedRegion::structure(input, line.span()));
640 continue;
641 }
642
643 if pragma_off {
644 state.flush();
645 state
646 .regions
647 .push(SpannedRegion::structure(input, line.span()));
648 continue;
649 }
650 }
651
652 if in_preamble {
654 if line.text.contains(r"\begin{document}") {
655 in_preamble = false;
656 }
657 state.flush();
658 state
659 .regions
660 .push(SpannedRegion::structure(input, line.span()));
661 continue;
662 }
663
664 if state.in_code_env.is_some() {
665 state.consume_code_env_line(line);
666 continue;
667 }
668
669 state.consume_body_line(line);
670 }
671
672 state.flush();
673 if state.in_code_env.is_some() {
674 let eof = ByteSpan::new(input.len(), input.len());
675 state.regions.push(SpannedRegion::code(
676 input,
677 state.code_lang.take(),
678 state.code_header,
679 ByteSpan::new(state.code_body_start, input.len()),
680 eof,
681 ));
682 }
683 state.regions
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::parser::Region;
691
692 #[test]
693 fn section_command_title_is_structure_not_prose() {
694 let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody.\n\\end{document}\n";
695 let regions = LatexParser::default().parse(input);
696 assert!(
697 regions.iter().any(|r| matches!(
698 r,
699 Region::Structure(s) if s.contains(r"\section{A long title. With two sentences.}")
700 )),
701 "full section line must be Structure, got: {regions:?}"
702 );
703 assert!(
704 !regions
705 .iter()
706 .any(|r| matches!(r, Region::Prose(p) if p.contains("A long title"))),
707 "section title must not be Prose: {regions:?}"
708 );
709 let prose: Vec<_> = regions
710 .iter()
711 .filter_map(|r| match r {
712 Region::Prose(t) => Some(t.as_str()),
713 _ => None,
714 })
715 .collect();
716 assert!(prose.contains(&"Body."));
717 }
718
719 #[test]
720 fn multi_sentence_section_title_stays_one_line() {
721 use crate::format::Format;
722 use crate::{FormatConfig, format_text};
723
724 let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody text here. More body.\n\\end{document}\n";
725 let cfg = FormatConfig {
726 format: Format::Latex,
727 ..Default::default()
728 }
729 .without_safety_backstops();
730 let out = format_text(input, &cfg).unwrap();
731 assert!(
732 out.contains("\\section{A long title. With two sentences.}"),
733 "section title must stay one line, got:\n{out}"
734 );
735 assert!(
736 !out.contains("\\section{A long title.\n"),
737 "must not reflow mid-title inside braces:\n{out}"
738 );
739 assert_eq!(format_text(&out, &cfg).unwrap(), out);
740 }
741
742 #[test]
743 fn preamble_is_structure() {
744 let input = r"\documentclass{article}
745\usepackage{amsmath}
746\begin{document}
747Hello world.
748\end{document}";
749 let regions = LatexParser::default().parse(input);
750 assert!(matches!(®ions[0], Region::Structure(_)));
752 assert!(matches!(®ions[1], Region::Structure(_)));
753 assert!(matches!(®ions[2], Region::Structure(_)));
754 let has_prose = regions.iter().any(|r| matches!(r, Region::Prose(_)));
756 assert!(has_prose);
757 }
758
759 #[test]
760 fn equation_preserved() {
761 let input = r"\begin{document}
762Some text here.
763\begin{equation}
764E = mc^2
765\end{equation}
766More text.
767\end{document}";
768 let regions = LatexParser::default().parse(input);
769 let structure_count = regions
770 .iter()
771 .filter(|r| matches!(r, Region::Structure(_)))
772 .count();
773 assert!(structure_count >= 4);
775 }
776
777 #[test]
778 fn comments_preserved() {
779 let input = r"\begin{document}
780% This is a comment
781Some text.
782\end{document}";
783 let regions = LatexParser::default().parse(input);
784 let comment_region = regions.iter().find(|r| {
785 if let Region::Structure(s) = r {
786 s.contains("% This is a comment")
787 } else {
788 false
789 }
790 });
791 assert!(comment_region.is_some());
792 }
793
794 #[test]
795 fn trailing_percent_is_nospace_join() {
796 use crate::format::Format;
797 use crate::{FormatConfig, format_text};
798
799 let input = "\\begin{document}\nfoo%\nbar. Next sentence.\n\\end{document}\n";
800 let cfg = FormatConfig {
801 format: Format::Latex,
802 ..Default::default()
803 }
804 .without_safety_backstops();
805 let out = format_text(input, &cfg).unwrap();
806 assert!(
807 !out.contains("foo% bar"),
808 "trailing % must not become a space comment: {out}"
809 );
810 assert!(
811 out.contains("foobar.") || out.contains("foo%\nbar."),
812 "foo%\\nbar must stay one TeX word, got:\n{out}"
813 );
814 assert!(out.contains("Next sentence."));
815 assert_eq!(format_text(&out, &cfg).unwrap(), out);
816 }
817
818 #[test]
819 fn escaped_percent_is_not_a_comment() {
820 use crate::format::Format;
821 use crate::{FormatConfig, format_text};
822
823 let input = "\\begin{document}\n50\\% of cases. More text.\n\\end{document}\n";
824 let cfg = FormatConfig {
825 format: Format::Latex,
826 ..Default::default()
827 }
828 .without_safety_backstops();
829 let out = format_text(input, &cfg).unwrap();
830 assert!(
831 out.contains("50\\% of cases."),
832 "escaped percent must stay in prose: {out}"
833 );
834 assert!(out.contains("More text."));
835 }
836
837 #[test]
838 fn mid_line_percent_comment_is_structure() {
839 use crate::format::Format;
840 use crate::{FormatConfig, format_text};
841
842 let input = "\\begin{document}\nSee Fig. 1. % TODO cite\nNext sentence.\n\\end{document}\n";
843 let cfg = FormatConfig {
844 format: Format::Latex,
845 ..Default::default()
846 }
847 .without_safety_backstops();
848 let out = format_text(input, &cfg).unwrap();
849 assert!(
850 out.contains("% TODO cite"),
851 "trailing comment must be kept: {out}"
852 );
853 let regions = LatexParser::default().parse(input);
854 assert!(
855 regions
856 .iter()
857 .any(|r| matches!(r, Region::Structure(s) if s.contains("% TODO cite"))),
858 "mid-line % comment must be Structure, got: {regions:?}"
859 );
860 assert!(
861 !regions
862 .iter()
863 .any(|r| matches!(r, Region::Prose(p) if p.contains("TODO"))),
864 "comment text must not stay in prose: {regions:?}"
865 );
866 }
867
868 fn latex_cfg() -> crate::FormatConfig {
869 crate::FormatConfig {
870 format: crate::format::Format::Latex,
871 ..Default::default()
872 }
873 .without_safety_backstops()
874 }
875
876 #[test]
877 fn verb_with_inner_punct_round_trips() {
878 use crate::format_text;
879
880 let input = "\\begin{document}\nUse \\verb|a.b! c| here. Next sentence.\n\\end{document}\n";
881 let out = format_text(input, &latex_cfg()).unwrap();
882 assert!(
883 out.contains(r"\verb|a.b! c|"),
884 "verb must stay intact, got:\n{out}"
885 );
886 assert!(
887 !out.contains("\\verb|a.\n") && !out.contains("\\verb|a.b!\n"),
888 "inner .!? must not split the verb, got:\n{out}"
889 );
890 assert!(
891 out.contains("Next sentence."),
892 "following sentence must remain, got:\n{out}"
893 );
894 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
895 }
896
897 #[test]
898 fn lstinline_inner_percent_is_not_a_comment() {
899 use crate::format_text;
900
901 let input =
902 "\\begin{document}\nCode \\lstinline!%! here. Next sentence.\n\\end{document}\n";
903 let out = format_text(input, &latex_cfg()).unwrap();
904 assert!(
905 out.contains(r"\lstinline!%!"),
906 "lstinline with inner % must stay intact, got:\n{out}"
907 );
908 assert!(
909 out.contains("here."),
910 "text after lstinline must not be commented out, got:\n{out}"
911 );
912 assert!(
913 out.contains("Next sentence."),
914 "following sentence must remain, got:\n{out}"
915 );
916 let regions = LatexParser::default().parse(input);
917 assert!(
918 !regions.iter().any(
919 |r| matches!(r, Region::Structure(s) if s.contains("%!") || s.trim() == "%!\n")
920 ),
921 "inner % of lstinline must not be a comment, got: {regions:?}"
922 );
923 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
924 }
925
926 #[test]
927 fn lstinline_optional_args_round_trip() {
928 use crate::format_text;
929
930 let input = "\\begin{document}\nSee \\lstinline[language=TeX]!a.b%! please. Next.\n\\end{document}\n";
931 let out = format_text(input, &latex_cfg()).unwrap();
932 assert!(
933 out.contains(r"\lstinline[language=TeX]!a.b%!"),
934 "lstinline optional args and inner % must stay, got:\n{out}"
935 );
936 assert!(
937 out.contains("please."),
938 "prose after lstinline must remain, got:\n{out}"
939 );
940 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
941 }
942
943 #[test]
944 fn unknown_theorem_env_is_region_boundary() {
945 use crate::format_text;
946
947 let input = "\\begin{document}\nBefore the claim. More before.\n\\begin{theorem}\nA statement. Another claim.\n\\end{theorem}\nAfter the claim. More after.\n\\end{document}\n";
948 let regions = LatexParser::default().parse(input);
949 assert!(
950 regions
951 .iter()
952 .any(|r| matches!(r, Region::Structure(s) if s.contains(r"\begin{theorem}"))),
953 "\\begin{{theorem}} must be Structure, got: {regions:?}"
954 );
955 assert!(
956 regions
957 .iter()
958 .any(|r| matches!(r, Region::Structure(s) if s.contains(r"\end{theorem}"))),
959 "\\end{{theorem}} must be Structure, got: {regions:?}"
960 );
961 let before_mixed = regions.iter().any(|r| {
962 matches!(
963 r,
964 Region::Prose(p) if p.contains("More before") && p.contains("A statement")
965 )
966 });
967 assert!(
968 !before_mixed,
969 "theorem begin must bound regions, not concatenate neighboring prose: {regions:?}"
970 );
971 let after_mixed = regions.iter().any(|r| {
972 matches!(
973 r,
974 Region::Prose(p) if p.contains("Another claim") && p.contains("After the claim")
975 )
976 });
977 assert!(
978 !after_mixed,
979 "theorem end must bound regions, not concatenate neighboring prose: {regions:?}"
980 );
981 assert!(
982 regions
983 .iter()
984 .any(|r| matches!(r, Region::Prose(p) if p.contains("A statement"))),
985 "theorem body must stay prose, got: {regions:?}"
986 );
987
988 let out = format_text(input, &latex_cfg()).unwrap();
989 assert!(
990 out.contains("\\begin{theorem}\n"),
991 "begin theorem must stay a boundary, got:\n{out}"
992 );
993 assert!(
994 out.contains("A statement.\nAnother claim."),
995 "theorem body must still reflow, got:\n{out}"
996 );
997 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
998 }
999
1000 #[test]
1001 fn mid_line_begin_equation_leaves_leading_words_as_prose() {
1002 let input = "\\begin{document}\ninducing \\begin{equation}\nE = mc^2\n\\end{equation}\nAfter.\n\\end{document}\n";
1003 let regions = LatexParser::default().parse(input);
1004 assert!(
1005 regions
1006 .iter()
1007 .any(|r| matches!(r, Region::Prose(p) if p.contains("inducing"))),
1008 "leading words before mid-line begin must be Prose, got: {regions:?}"
1009 );
1010 assert!(
1011 !regions.iter().any(|r| {
1012 matches!(
1013 r,
1014 Region::Structure(s) if s.contains("inducing") && s.contains(r"\begin{equation}")
1015 )
1016 }),
1017 "leading words must not be marked Structure with the env, got: {regions:?}"
1018 );
1019 assert!(
1020 regions
1021 .iter()
1022 .any(|r| matches!(r, Region::Structure(s) if s.contains(r"\begin{equation}"))),
1023 "equation begin must still be Structure, got: {regions:?}"
1024 );
1025 }
1026
1027 #[test]
1028 fn nested_same_name_envs_close_on_matching_depth() {
1029 let input = "\\begin{document}\n\\begin{equation}\n\\begin{equation}\nx = 1\n\\end{equation}\ny = 2\n\\end{equation}\nAfter the nest. Next.\n\\end{document}\n";
1030 let regions = LatexParser::default().parse(input);
1031 assert!(
1032 !regions
1033 .iter()
1034 .any(|r| matches!(r, Region::Prose(p) if p.contains("y = 2"))),
1035 "inner \\end must not close the outer equation; y = 2 stays Structure, got: {regions:?}"
1036 );
1037 assert!(
1038 regions
1039 .iter()
1040 .any(|r| matches!(r, Region::Prose(p) if p.contains("After the nest"))),
1041 "prose after the outer end must resume, got: {regions:?}"
1042 );
1043 let y_is_structure = regions
1044 .iter()
1045 .any(|r| matches!(r, Region::Structure(s) if s.contains("y = 2")));
1046 assert!(
1047 y_is_structure,
1048 "y = 2 must remain inside the outer equation Structure, got: {regions:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn unmatched_verb_inner_percent_is_not_a_comment() {
1054 use crate::format_text;
1055
1056 let input = "\\begin{document}\nSee \\verb|a%b. Next sentence.\n\\end{document}\n";
1057 let regions = LatexParser::default().parse(input);
1058 assert!(
1059 !regions.iter().any(|r| {
1060 matches!(r, Region::Structure(s) if s.contains("%b") || s.contains("%b."))
1061 }),
1062 "unmatched \\verb|a%b must not treat % as a comment, got: {regions:?}"
1063 );
1064 assert!(
1065 regions
1066 .iter()
1067 .any(|r| matches!(r, Region::Prose(p) if p.contains(r"\verb|a%b"))),
1068 "unmatched verb must stay in prose through EOL, got: {regions:?}"
1069 );
1070 let out = format_text(input, &latex_cfg()).unwrap();
1071 assert!(
1072 out.contains(r"\verb|a%b"),
1073 "unmatched verb must keep inner %, got:\n{out}"
1074 );
1075 assert!(
1076 out.contains("Next sentence.") || out.contains(r"\verb|a%b. Next sentence."),
1077 "text after % must not be commented out, got:\n{out}"
1078 );
1079 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
1080 }
1081
1082 #[test]
1083 fn lstlisting_end_python_does_not_steal_the_close() {
1084 use crate::format_text;
1085
1086 let input = "\\begin{document}\nBefore.\n\\begin{lstlisting}\nprint(1)\n\\end{python} \\end{lstlisting}\nAfter the listing. Next.\n\\end{document}\n";
1087 let regions = LatexParser::default().parse(input);
1088 let code = regions.iter().find_map(|r| match r {
1089 Region::Code { body, footer, .. } => Some((body.as_str(), footer.as_str())),
1090 _ => None,
1091 });
1092 let (body, footer) = code.expect(&format!("lstlisting must be Code, got: {regions:?}"));
1093 assert!(
1094 body.contains("print(1)"),
1095 "listing body must keep source, got body={body:?} regions={regions:?}"
1096 );
1097 assert!(
1098 body.contains(r"\end{python}"),
1099 "\\end{{python}} is listing content, got body={body:?}"
1100 );
1101 assert!(
1102 !body.contains(r"\end{lstlisting}"),
1103 "real closer must not stay in the body, got body={body:?}"
1104 );
1105 assert!(
1106 footer.contains(r"\end{lstlisting}"),
1107 "footer must be \\end{{lstlisting}}, got footer={footer:?}"
1108 );
1109 assert!(
1110 regions
1111 .iter()
1112 .any(|r| matches!(r, Region::Prose(p) if p.contains("After the listing"))),
1113 "prose after the listing must resume, got: {regions:?}"
1114 );
1115 let out = format_text(input, &latex_cfg()).unwrap();
1116 assert!(
1117 out.contains("After the listing."),
1118 "text after lstlisting must remain, got:\n{out}"
1119 );
1120 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
1121 }
1122
1123 #[test]
1124 fn nested_same_name_verbatim_closes_on_matching_depth() {
1125 let input = "\\begin{document}\n\\begin{verbatim}\n\\begin{verbatim}\ninner\n\\end{verbatim}\nstill body\n\\end{verbatim}\nAfter the nest.\n\\end{document}\n";
1126 let regions = LatexParser::default().parse(input);
1127 let code = regions.iter().find_map(|r| match r {
1128 Region::Code { body, footer, .. } => Some((body.as_str(), footer.as_str())),
1129 _ => None,
1130 });
1131 let (body, footer) = code.expect(&format!("verbatim must be Code, got: {regions:?}"));
1132 assert!(
1133 body.contains("still body"),
1134 "inner \\end must not close the outer verbatim; still body stays in the listing, got body={body:?} regions={regions:?}"
1135 );
1136 assert!(
1137 body.contains("inner"),
1138 "inner content must stay in the listing, got body={body:?}"
1139 );
1140 assert!(
1141 footer.contains(r"\end{verbatim}"),
1142 "outer closer is the footer, got footer={footer:?}"
1143 );
1144 assert!(
1145 regions
1146 .iter()
1147 .any(|r| matches!(r, Region::Prose(p) if p.contains("After the nest"))),
1148 "prose after the outer end must resume, got: {regions:?}"
1149 );
1150 assert!(
1151 !regions
1152 .iter()
1153 .any(|r| matches!(r, Region::Prose(p) if p.contains("still body"))),
1154 "still body must not leak into prose, got: {regions:?}"
1155 );
1156 }
1157
1158 #[test]
1159 fn lstlisting_percent_does_not_hide_end() {
1160 use crate::format_text;
1161
1162 let input = "\\begin{document}\n\\begin{lstlisting}\nprint(1) % \\end{lstlisting}\nAfter the listing. Next.\n\\end{document}\n";
1163 let regions = LatexParser::default().parse(input);
1164 let code = regions.iter().find_map(|r| match r {
1165 Region::Code { body, footer, .. } => Some((body.as_str(), footer.as_str())),
1166 _ => None,
1167 });
1168 let (body, footer) = code.expect(&format!("lstlisting must be Code, got: {regions:?}"));
1169 assert!(
1170 body.contains("print(1)"),
1171 "listing body must keep source before %, got body={body:?} regions={regions:?}"
1172 );
1173 assert!(
1174 !body.contains("After the listing"),
1175 "% must not hide \\end{{lstlisting}}; after-text is not listing body, got body={body:?} regions={regions:?}"
1176 );
1177 assert!(
1178 footer.contains(r"\end{lstlisting}"),
1179 "footer must be \\end{{lstlisting}} even after %, got footer={footer:?} regions={regions:?}"
1180 );
1181 assert!(
1182 regions
1183 .iter()
1184 .any(|r| matches!(r, Region::Prose(p) if p.contains("After the listing"))),
1185 "prose after the listing must resume, got: {regions:?}"
1186 );
1187 let out = format_text(input, &latex_cfg()).unwrap();
1188 assert!(
1189 out.contains("After the listing."),
1190 "text after lstlisting must remain, got:\n{out}"
1191 );
1192 assert!(
1193 out.contains("Next."),
1194 "following sentence must remain, got:\n{out}"
1195 );
1196 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
1197 }
1198
1199 #[test]
1200 fn lstlisting_same_line_percent_in_string_does_not_hide_end() {
1201 use crate::format_text;
1202
1203 let input = "\\begin{document}\n\\begin{lstlisting} print(\"%\") \\end{lstlisting}\nAfter.\n\\end{document}\n";
1204 let regions = LatexParser::default().parse(input);
1205 assert!(
1206 regions.iter().any(|r| matches!(r, Region::Code { .. })),
1207 "same-line lstlisting must be Code, got: {regions:?}"
1208 );
1209 assert!(
1210 regions
1211 .iter()
1212 .any(|r| matches!(r, Region::Prose(p) if p.contains("After"))),
1213 "prose after same-line listing must resume, got: {regions:?}"
1214 );
1215 assert!(
1216 !regions
1217 .iter()
1218 .any(|r| matches!(r, Region::Prose(p) if p.contains(r"\end{lstlisting}"))),
1219 "\\end{{lstlisting}} after % in a string must still close, got: {regions:?}"
1220 );
1221 let out = format_text(input, &latex_cfg()).unwrap();
1222 assert!(
1223 out.contains("After."),
1224 "text after same-line lstlisting must remain, got:\n{out}"
1225 );
1226 assert!(
1227 out.contains(r"\end{lstlisting}"),
1228 "closer must survive, got:\n{out}"
1229 );
1230 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
1231 }
1232
1233 #[test]
1234 fn theorem_optional_args_stay_on_the_begin_token() {
1235 let input = "\\begin{document}\nBefore.\n\\begin{theorem}[A. B. C.]\nA statement. Another.\n\\end{theorem}\nAfter.\n\\end{document}\n";
1236 let regions = LatexParser::default().parse(input);
1237 assert!(
1238 regions.iter().any(|r| {
1239 matches!(r, Region::Structure(s) if s.contains(r"\begin{theorem}[A. B. C.]"))
1240 }),
1241 "optional [A. B. C.] must stay on the begin token, got: {regions:?}"
1242 );
1243 assert!(
1244 !regions
1245 .iter()
1246 .any(|r| matches!(r, Region::Prose(p) if p.contains("A. B. C."))),
1247 "theorem optional title must not become prose, got: {regions:?}"
1248 );
1249 assert!(
1250 regions
1251 .iter()
1252 .any(|r| matches!(r, Region::Prose(p) if p.contains("A statement"))),
1253 "theorem body must stay prose, got: {regions:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn missing_env_keys_keep_builtin_algorithm_as_prose() {
1259 use crate::format_text;
1260
1261 let input = "\\begin{document}\nBefore the algo. More before.\n\\begin{algorithm}\nFirst step. Second step.\n\\end{algorithm}\nAfter the algo. More after.\n\\end{document}\n";
1263 let out = format_text(input, &latex_cfg()).unwrap();
1264 assert!(
1265 out.contains("First step.\nSecond step."),
1266 "unlisted algorithm body must still reflow, got:\n{out}"
1267 );
1268 assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
1269 }
1270
1271 #[test]
1272 fn configured_structure_envs_stop_algorithm_and_comment_reflow() {
1273 use crate::format_text;
1274
1275 let input = "\\begin{document}\nBefore.\n\\begin{algorithm}\nFirst step. Second step.\n\\end{algorithm}\n\\begin{comment}\nHidden one. Hidden two.\n\\end{comment}\nAfter the block. Next.\n\\end{document}\n";
1276 let cfg = crate::FormatConfig {
1277 format: crate::format::Format::Latex,
1278 latex_structure_envs: vec!["algorithm".into(), "comment".into()],
1279 ..Default::default()
1280 }
1281 .without_safety_backstops();
1282 let out = format_text(input, &cfg).unwrap();
1283 assert!(
1284 out.contains("First step. Second step."),
1285 "algorithm body must not reflow, got:\n{out}"
1286 );
1287 assert!(
1288 !out.contains("First step.\nSecond step."),
1289 "algorithm must stay one source line, got:\n{out}"
1290 );
1291 assert!(
1292 out.contains("Hidden one. Hidden two."),
1293 "comment body must not reflow, got:\n{out}"
1294 );
1295 assert!(
1296 !out.contains("Hidden one.\nHidden two."),
1297 "comment env must stay one source line, got:\n{out}"
1298 );
1299 assert!(
1300 out.contains("After the block.\nNext."),
1301 "prose after configured envs must still reflow, got:\n{out}"
1302 );
1303 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1304 }
1305
1306 #[test]
1307 fn configured_verbatim_env_stops_fancyvrb_reflow() {
1308 use crate::format_text;
1309
1310 let input = "\\begin{document}\nBefore.\n\\begin{Verbatim}\nFirst line. Second line.\n\\end{Verbatim}\nAfter the listing. Next.\n\\end{document}\n";
1311 let default_out = format_text(input, &latex_cfg()).unwrap();
1312 assert!(
1313 default_out.contains("First line.\nSecond line."),
1314 "unlisted Verbatim body is prose and reflows, got:\n{default_out}"
1315 );
1316
1317 let cfg = crate::FormatConfig {
1318 format: crate::format::Format::Latex,
1319 latex_verbatim_envs: vec!["Verbatim".into()],
1320 ..Default::default()
1321 }
1322 .without_safety_backstops();
1323 let out = format_text(input, &cfg).unwrap();
1324 assert!(
1325 out.contains("First line. Second line."),
1326 "configured Verbatim body must not reflow, got:\n{out}"
1327 );
1328 assert!(
1329 !out.contains("First line.\nSecond line."),
1330 "Verbatim must stay verbatim, got:\n{out}"
1331 );
1332 let regions = LatexParser::from_config(Some(&cfg)).parse(input);
1333 assert!(
1334 regions.iter().any(|r| matches!(
1335 r,
1336 Region::Code { body, .. } if body.contains("First line. Second line.")
1337 )),
1338 "configured Verbatim must be Code, got: {regions:?}"
1339 );
1340 assert!(
1341 out.contains("After the listing.\nNext."),
1342 "prose after Verbatim must still reflow, got:\n{out}"
1343 );
1344 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1345 }
1346
1347 #[test]
1348 fn configured_verbatim_command_is_tokenized_like_verb() {
1349 use crate::format_text;
1350
1351 let input = "\\begin{document}\nUse \\Verb|a.b! c| here. Next sentence.\n\\end{document}\n";
1352 let default_out = format_text(input, &latex_cfg()).unwrap();
1353 assert!(
1354 !default_out.contains("Use \\Verb|a.b! c| here.\nNext sentence."),
1355 "unlisted Verb must not stay atomic like verb, got:\n{default_out}"
1356 );
1357
1358 let cfg = crate::FormatConfig {
1359 format: crate::format::Format::Latex,
1360 latex_verbatim_commands: vec!["Verb".into()],
1361 ..Default::default()
1362 }
1363 .without_safety_backstops();
1364 let out = format_text(input, &cfg).unwrap();
1365 assert!(
1366 out.contains(r"\Verb|a.b! c|"),
1367 "configured Verb must stay intact, got:\n{out}"
1368 );
1369 assert!(
1370 !out.contains("\\Verb|a.\n") && !out.contains("\\Verb|a.b!\n"),
1371 "inner .!? must not split configured Verb, got:\n{out}"
1372 );
1373 assert!(
1374 out.contains("Use \\Verb|a.b! c| here.\nNext sentence."),
1375 "configured Verb must tokenize like verb before split, got:\n{out}"
1376 );
1377 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1378 }
1379
1380 #[test]
1381 fn configured_lists_keep_builtin_minted_and_equation() {
1382 use crate::format_text;
1383
1384 let input = "\\begin{document}\nIntro. More intro.\n\\begin{equation}\nE = mc^2\n\\end{equation}\n\\begin{minted}{python}\nprint(1)\nprint(2)\n\\end{minted}\nAfter. Next.\n\\end{document}\n";
1385 let cfg = crate::FormatConfig {
1386 format: crate::format::Format::Latex,
1387 latex_verbatim_envs: vec!["Verbatim".into()],
1388 latex_structure_envs: vec!["algorithm".into()],
1389 latex_verbatim_commands: vec!["Verb".into()],
1390 ..Default::default()
1391 }
1392 .without_safety_backstops();
1393 let out = format_text(input, &cfg).unwrap();
1394 assert!(
1395 out.contains("\\begin{equation}\nE = mc^2\n\\end{equation}"),
1396 "built-in equation must stay structure, got:\n{out}"
1397 );
1398 assert!(
1399 out.contains("\\begin{minted}{python}\nprint(1)\nprint(2)\n\\end{minted}"),
1400 "built-in minted must stay a code env, got:\n{out}"
1401 );
1402 assert!(
1403 out.contains("Intro.\nMore intro."),
1404 "surrounding prose must still reflow, got:\n{out}"
1405 );
1406 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1407 }
1408
1409 #[test]
1410 fn configured_lists_keep_eq_ref_nbsp() {
1411 use crate::format_text;
1412
1413 let input = "\\begin{document}\nSee Eq.~\\ref{eq:diff}. Next.\n\\end{document}\n";
1414 let cfg = crate::FormatConfig {
1415 format: crate::format::Format::Latex,
1416 latex_verbatim_envs: vec!["Verbatim".into()],
1417 latex_structure_envs: vec!["algorithm".into()],
1418 latex_verbatim_commands: vec!["Verb".into()],
1419 ..Default::default()
1420 }
1421 .without_safety_backstops();
1422 let out = format_text(input, &cfg).unwrap();
1423 assert!(
1424 out.contains("Eq.~\\ref{eq:diff}."),
1425 "must not invent a space before ~, got:\n{out}"
1426 );
1427 assert!(
1428 !out.contains("Eq. ~"),
1429 "abbreviation merge must not insert a space before ~, got:\n{out}"
1430 );
1431 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1432 }
1433
1434 #[test]
1435 fn configured_verb_inner_percent_is_not_a_comment() {
1436 use crate::format_text;
1437
1438 let input = "\\begin{document}\nCode \\Verb!%! here. Next sentence.\n\\end{document}\n";
1439 let cfg = crate::FormatConfig {
1440 format: crate::format::Format::Latex,
1441 latex_verbatim_commands: vec!["Verb".into()],
1442 ..Default::default()
1443 };
1444 assert!(
1445 cfg.render_backstop && cfg.fixpoint_backstop,
1446 "this case is the production backstop path"
1447 );
1448 let out = format_text(input, &cfg).unwrap();
1449 assert!(
1450 out.contains(r"\Verb!%!"),
1451 "configured Verb with inner % must stay intact, got:\n{out}"
1452 );
1453 assert!(
1454 out.contains("Code \\Verb!%! here.\nNext sentence."),
1455 "production backstops must not revert the whole file, got:\n{out}"
1456 );
1457 assert!(
1458 !out.contains("Code \\Verb!%! here. Next sentence."),
1459 "inner % is not a comment; the fused line must split, got:\n{out}"
1460 );
1461 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1462 }
1463
1464 #[test]
1465 fn configured_verb_does_not_steal_verbatim() {
1466 use crate::format_text;
1467
1468 let input =
1469 "\\begin{document}\nUse \\Verbatim|x.y| here. Next sentence.\n\\end{document}\n";
1470 let cfg = crate::FormatConfig {
1471 format: crate::format::Format::Latex,
1472 latex_verbatim_commands: vec!["Verb".into()],
1473 ..Default::default()
1474 };
1475 let out = format_text(input, &cfg).unwrap();
1476 assert!(
1477 out.contains("Use \\Verbatim|x.y| here.\nNext sentence."),
1478 "\\Verb must not consume \\Verbatim, so the next sentence must split, got:\n{out}"
1479 );
1480 assert!(
1481 out.contains(r"\Verbatim|x.y|"),
1482 "\\Verbatim must remain in the source, got:\n{out}"
1483 );
1484 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1485 }
1486}