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