Skip to main content

snapper_fmt/parser/
latex.rs

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
7// Environments whose content is NOT prose (math, code, figures, tables)
8static 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
35/// `\begin{minted}{LANG}` -- the language is the brace argument after the env.
36static MINTED_LANG_RE: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"\\begin\{minted\}\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}").unwrap());
38
39/// `\begin{lstlisting}[language=LANG, ...]` -- language is an option key.
40static LSTLISTING_LANG_RE: LazyLock<Regex> = LazyLock::new(|| {
41    Regex::new(r"\\begin\{lstlisting\}\s*\[[^\]]*language\s*=\s*([A-Za-z0-9_+.\-]+)").unwrap()
42});
43
44/// Built-in source-code environments whose body is `Region::Code`.
45fn 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
53/// Sectioning commands whose brace argument is prose (titles can be long).
54/// Captures: (1) command + opening brace prefix, (2) argument body, (3) closing brace + rest.
55static 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    /// Byte offset of the first `%` that is not escaped as `\%` and is not
86    /// inside `\verb` / `\lstinline` / configured verbatim commands.
87    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/// `\begin{name}` or `\end{name}` on a line.
125#[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
146/// Through EOL when `rel..` is only whitespace; otherwise just `rel`.
147fn 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
256/// `\begin{name}` / `\end{name}` as raw source (lstlisting/verbatim/minted).
257/// `%` and `\verb` are content, not a TeX comment or a skipped span.
258fn 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    /// Returns true when the physical `line` is fully consumed.
531    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            // Check for snapper:off/on pragmas; inside a code environment
632            // the per-language reflow path handles pragmas instead.
633            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            // Preamble: everything before \begin{document} is structure
653            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        // First 3 lines are preamble structure (including \begin{document})
751        assert!(matches!(&regions[0], Region::Structure(_)));
752        assert!(matches!(&regions[1], Region::Structure(_)));
753        assert!(matches!(&regions[2], Region::Structure(_)));
754        // "Hello world." is prose
755        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        // Preamble line + begin{equation} + E=mc^2 + end{equation} + end{document}
774        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        // algorithm is not in NON_PROSE_ENVS; missing config keeps that list.
1262        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}