Skip to main content

snapper_fmt/parser/
latex.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
5
6// Environments whose content is NOT prose (math, code, figures, tables)
7static NON_PROSE_ENVS: &[&str] = &[
8    "equation",
9    "equation*",
10    "align",
11    "align*",
12    "gather",
13    "gather*",
14    "multline",
15    "multline*",
16    "eqnarray",
17    "eqnarray*",
18    "figure",
19    "figure*",
20    "table",
21    "table*",
22    "tabular",
23    "tabular*",
24    "lstlisting",
25    "verbatim",
26    "minted",
27    "tikzpicture",
28    "array",
29    "matrix",
30    "pmatrix",
31    "bmatrix",
32];
33
34static BEGIN_ENV_RE: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r"\\begin\{(\w+\*?)\}").unwrap());
36
37static END_ENV_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\end\{(\w+\*?)\}").unwrap());
38
39/// `\begin{minted}{LANG}` -- the language is the brace argument after the env.
40static MINTED_LANG_RE: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"\\begin\{minted\}\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}").unwrap());
42
43/// `\begin{lstlisting}[language=LANG, ...]` -- language is an option key.
44static LSTLISTING_LANG_RE: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(r"\\begin\{lstlisting\}\s*\[[^\]]*language\s*=\s*([A-Za-z0-9_+.\-]+)").unwrap()
46});
47
48/// Source-code environments whose body should be emitted as `Region::Code`.
49fn is_code_env(name: &str) -> bool {
50    matches!(name, "minted" | "lstlisting" | "verbatim")
51}
52
53static DISPLAY_MATH_OPEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\\\[").unwrap());
54
55static DISPLAY_MATH_CLOSE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\]\s*$").unwrap());
56
57/// Sectioning commands whose brace argument is prose (titles can be long).
58/// Captures: (1) command + opening brace prefix, (2) argument body, (3) closing brace + rest.
59static SECTION_CMD_RE: LazyLock<Regex> = LazyLock::new(|| {
60    Regex::new(
61        r"^(\s*\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{)([^}]*)(\}.*)$",
62    )
63    .unwrap()
64});
65
66pub struct LatexParser;
67
68impl LatexParser {
69    fn is_comment(line: &str) -> bool {
70        line.trim_start().starts_with('%')
71    }
72
73    fn is_non_prose_env(name: &str) -> bool {
74        NON_PROSE_ENVS.contains(&name)
75    }
76}
77
78impl FormatParser for LatexParser {
79    fn parse(&self, input: &str) -> Vec<Region> {
80        let mut regions: Vec<Region> = Vec::new();
81        let mut current_prose = String::new();
82        let mut in_preamble = true;
83        let mut in_non_prose_env: Option<String> = None;
84        // Code environment bookkeeping.
85        let mut in_code_env: Option<String> = None;
86        let mut code_lang: Option<String> = None;
87        let mut code_header = String::new();
88        let mut code_body = String::new();
89        let mut in_display_math = false;
90        let mut pragma_off = false;
91
92        for line in input.lines() {
93            // Check for snapper:off/on pragmas; inside a code environment
94            // the per-language reflow path handles pragmas instead.
95            if in_code_env.is_none() {
96                if let Some(on) = super::check_pragma(line) {
97                    flush_prose(&mut current_prose, &mut regions);
98                    pragma_off = !on;
99                    regions.push(Region::Structure(format!("{line}\n")));
100                    continue;
101                }
102
103                if pragma_off {
104                    flush_prose(&mut current_prose, &mut regions);
105                    regions.push(Region::Structure(format!("{line}\n")));
106                    continue;
107                }
108            }
109
110            // Preamble: everything before \begin{document} is structure
111            if in_preamble {
112                if line.contains(r"\begin{document}") {
113                    in_preamble = false;
114                }
115                flush_prose(&mut current_prose, &mut regions);
116                regions.push(Region::Structure(format!("{line}\n")));
117                continue;
118            }
119
120            // Inside code environment -- buffer body
121            if let Some(env_name) = in_code_env.clone() {
122                flush_prose(&mut current_prose, &mut regions);
123                let ends = END_ENV_RE
124                    .captures(line)
125                    .map(|c| c.get(1).unwrap().as_str() == env_name)
126                    .unwrap_or(false);
127                if ends {
128                    in_code_env = None;
129                    regions.push(Region::Code {
130                        lang: code_lang.take(),
131                        header: std::mem::take(&mut code_header),
132                        body: std::mem::take(&mut code_body),
133                        footer: format!("{line}\n"),
134                    });
135                } else {
136                    code_body.push_str(line);
137                    code_body.push('\n');
138                }
139                continue;
140            }
141
142            // Inside non-prose environment
143            if let Some(ref env_name) = in_non_prose_env {
144                flush_prose(&mut current_prose, &mut regions);
145                if let Some(caps) = END_ENV_RE.captures(line) {
146                    if caps.get(1).unwrap().as_str() == env_name {
147                        in_non_prose_env = None;
148                    }
149                }
150                regions.push(Region::Structure(format!("{line}\n")));
151                continue;
152            }
153
154            // Inside display math \[...\]
155            if in_display_math {
156                flush_prose(&mut current_prose, &mut regions);
157                if DISPLAY_MATH_CLOSE.is_match(line) {
158                    in_display_math = false;
159                }
160                regions.push(Region::Structure(format!("{line}\n")));
161                continue;
162            }
163
164            // Blank line
165            if line.trim().is_empty() {
166                flush_prose(&mut current_prose, &mut regions);
167                regions.push(Region::BlankLines(format!("{line}\n")));
168                continue;
169            }
170
171            // Comment
172            if Self::is_comment(line) {
173                flush_prose(&mut current_prose, &mut regions);
174                regions.push(Region::Structure(format!("{line}\n")));
175                continue;
176            }
177
178            // \end{document}
179            if line.contains(r"\end{document}") {
180                flush_prose(&mut current_prose, &mut regions);
181                regions.push(Region::Structure(format!("{line}\n")));
182                continue;
183            }
184
185            // Begin non-prose environment
186            if let Some(caps) = BEGIN_ENV_RE.captures(line) {
187                let env_name = caps.get(1).unwrap().as_str().to_string();
188                if Self::is_non_prose_env(&env_name) {
189                    flush_prose(&mut current_prose, &mut regions);
190                    // Single-line \begin{...}...\end{...}: emit as Structure
191                    // (or as an empty-body Code region for code envs) -- the
192                    // common case is multi-line, so keep this path simple.
193                    if let Some(end_caps) = END_ENV_RE.captures(line) {
194                        if end_caps.get(1).unwrap().as_str() == env_name {
195                            if is_code_env(&env_name) {
196                                regions.push(Region::Code {
197                                    lang: None,
198                                    header: format!("{line}\n"),
199                                    body: String::new(),
200                                    footer: String::new(),
201                                });
202                            } else {
203                                regions.push(Region::Structure(format!("{line}\n")));
204                            }
205                            continue;
206                        }
207                    }
208                    if is_code_env(&env_name) {
209                        code_lang = if env_name == "minted" {
210                            MINTED_LANG_RE
211                                .captures(line)
212                                .map(|c| c.get(1).unwrap().as_str().to_string())
213                        } else if env_name == "lstlisting" {
214                            LSTLISTING_LANG_RE
215                                .captures(line)
216                                .map(|c| c.get(1).unwrap().as_str().to_string())
217                        } else {
218                            None
219                        };
220                        code_header = format!("{line}\n");
221                        code_body.clear();
222                        in_code_env = Some(env_name);
223                    } else {
224                        in_non_prose_env = Some(env_name);
225                        regions.push(Region::Structure(format!("{line}\n")));
226                    }
227                    continue;
228                }
229            }
230
231            // Sectioning commands: keep the entire line as Structure.
232            // Splitting Structure(\section{)+Prose(title)+Structure(}) reflowed
233            // multi-sentence titles mid-brace. Single-line sectioning is not
234            // prose; do not reflow titles.
235            if SECTION_CMD_RE.is_match(line) {
236                flush_prose(&mut current_prose, &mut regions);
237                regions.push(Region::Structure(format!("{line}\n")));
238                continue;
239            }
240
241            // Display math \[
242            if DISPLAY_MATH_OPEN.is_match(line) && !DISPLAY_MATH_CLOSE.is_match(line) {
243                flush_prose(&mut current_prose, &mut regions);
244                in_display_math = true;
245                regions.push(Region::Structure(format!("{line}\n")));
246                continue;
247            }
248
249            // Single-line display math \[...\]
250            if DISPLAY_MATH_OPEN.is_match(line) && DISPLAY_MATH_CLOSE.is_match(line) {
251                flush_prose(&mut current_prose, &mut regions);
252                regions.push(Region::Structure(format!("{line}\n")));
253                continue;
254            }
255
256            // Regular prose line
257            if !current_prose.is_empty() {
258                current_prose.push(' ');
259            }
260            current_prose.push_str(line.trim());
261        }
262
263        flush_prose(&mut current_prose, &mut regions);
264        if in_code_env.is_some() {
265            regions.push(Region::Code {
266                lang: code_lang.take(),
267                header: std::mem::take(&mut code_header),
268                body: std::mem::take(&mut code_body),
269                footer: String::new(),
270            });
271        }
272        regions
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn section_command_title_is_structure_not_prose() {
282        let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody.\n\\end{document}\n";
283        let regions = LatexParser.parse(input);
284        assert!(
285            regions.iter().any(|r| matches!(
286                r,
287                Region::Structure(s) if s.contains(r"\section{A long title. With two sentences.}")
288            )),
289            "full section line must be Structure, got: {regions:?}"
290        );
291        assert!(
292            !regions
293                .iter()
294                .any(|r| matches!(r, Region::Prose(p) if p.contains("A long title"))),
295            "section title must not be Prose: {regions:?}"
296        );
297        let prose: Vec<_> = regions
298            .iter()
299            .filter_map(|r| match r {
300                Region::Prose(t) => Some(t.as_str()),
301                _ => None,
302            })
303            .collect();
304        assert!(prose.contains(&"Body."));
305    }
306
307    #[test]
308    fn multi_sentence_section_title_stays_one_line() {
309        use crate::format::Format;
310        use crate::{FormatConfig, format_text};
311
312        let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody text here. More body.\n\\end{document}\n";
313        let cfg = FormatConfig {
314            format: Format::Latex,
315            ..Default::default()
316        };
317        let out = format_text(input, &cfg).unwrap();
318        assert!(
319            out.contains("\\section{A long title. With two sentences.}"),
320            "section title must stay one line, got:\n{out}"
321        );
322        assert!(
323            !out.contains("\\section{A long title.\n"),
324            "must not reflow mid-title inside braces:\n{out}"
325        );
326        assert_eq!(format_text(&out, &cfg).unwrap(), out);
327    }
328
329    #[test]
330    fn preamble_is_structure() {
331        let input = r"\documentclass{article}
332\usepackage{amsmath}
333\begin{document}
334Hello world.
335\end{document}";
336        let regions = LatexParser.parse(input);
337        // First 3 lines are preamble structure (including \begin{document})
338        assert!(matches!(&regions[0], Region::Structure(_)));
339        assert!(matches!(&regions[1], Region::Structure(_)));
340        assert!(matches!(&regions[2], Region::Structure(_)));
341        // "Hello world." is prose
342        let has_prose = regions.iter().any(|r| matches!(r, Region::Prose(_)));
343        assert!(has_prose);
344    }
345
346    #[test]
347    fn equation_preserved() {
348        let input = r"\begin{document}
349Some text here.
350\begin{equation}
351E = mc^2
352\end{equation}
353More text.
354\end{document}";
355        let regions = LatexParser.parse(input);
356        let structure_count = regions
357            .iter()
358            .filter(|r| matches!(r, Region::Structure(_)))
359            .count();
360        // Preamble line + begin{equation} + E=mc^2 + end{equation} + end{document}
361        assert!(structure_count >= 4);
362    }
363
364    #[test]
365    fn comments_preserved() {
366        let input = r"\begin{document}
367% This is a comment
368Some text.
369\end{document}";
370        let regions = LatexParser.parse(input);
371        let comment_region = regions.iter().find(|r| {
372            if let Region::Structure(s) = r {
373                s.contains("% This is a comment")
374            } else {
375                false
376            }
377        });
378        assert!(comment_region.is_some());
379    }
380}