Skip to main content

snapper_fmt/parser/
latex.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region};
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
39static DISPLAY_MATH_OPEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\\\[").unwrap());
40
41static DISPLAY_MATH_CLOSE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\]\s*$").unwrap());
42
43pub struct LatexParser;
44
45impl LatexParser {
46    fn is_comment(line: &str) -> bool {
47        line.trim_start().starts_with('%')
48    }
49
50    fn is_non_prose_env(name: &str) -> bool {
51        NON_PROSE_ENVS.contains(&name)
52    }
53}
54
55impl FormatParser for LatexParser {
56    fn parse(&self, input: &str) -> Vec<Region> {
57        let mut regions: Vec<Region> = Vec::new();
58        let mut current_prose = String::new();
59        let mut in_preamble = true;
60        let mut in_non_prose_env: Option<String> = None;
61        let mut in_display_math = false;
62        let mut pragma_off = false;
63
64        let flush_prose = |prose: &mut String, regions: &mut Vec<Region>| {
65            if !prose.is_empty() {
66                regions.push(Region::Prose(prose.clone()));
67                prose.clear();
68            }
69        };
70
71        for line in input.lines() {
72            // Check for snapper:off/on pragmas
73            if let Some(on) = super::check_pragma(line) {
74                flush_prose(&mut current_prose, &mut regions);
75                pragma_off = !on;
76                regions.push(Region::Structure(format!("{line}\n")));
77                continue;
78            }
79
80            if pragma_off {
81                flush_prose(&mut current_prose, &mut regions);
82                regions.push(Region::Structure(format!("{line}\n")));
83                continue;
84            }
85
86            // Preamble: everything before \begin{document} is structure
87            if in_preamble {
88                if line.contains(r"\begin{document}") {
89                    in_preamble = false;
90                }
91                flush_prose(&mut current_prose, &mut regions);
92                regions.push(Region::Structure(format!("{line}\n")));
93                continue;
94            }
95
96            // Inside non-prose environment
97            if let Some(ref env_name) = in_non_prose_env {
98                flush_prose(&mut current_prose, &mut regions);
99                if let Some(caps) = END_ENV_RE.captures(line) {
100                    if caps.get(1).unwrap().as_str() == env_name {
101                        in_non_prose_env = None;
102                    }
103                }
104                regions.push(Region::Structure(format!("{line}\n")));
105                continue;
106            }
107
108            // Inside display math \[...\]
109            if in_display_math {
110                flush_prose(&mut current_prose, &mut regions);
111                if DISPLAY_MATH_CLOSE.is_match(line) {
112                    in_display_math = false;
113                }
114                regions.push(Region::Structure(format!("{line}\n")));
115                continue;
116            }
117
118            // Blank line
119            if line.trim().is_empty() {
120                flush_prose(&mut current_prose, &mut regions);
121                regions.push(Region::BlankLines(format!("{line}\n")));
122                continue;
123            }
124
125            // Comment
126            if Self::is_comment(line) {
127                flush_prose(&mut current_prose, &mut regions);
128                regions.push(Region::Structure(format!("{line}\n")));
129                continue;
130            }
131
132            // \end{document}
133            if line.contains(r"\end{document}") {
134                flush_prose(&mut current_prose, &mut regions);
135                regions.push(Region::Structure(format!("{line}\n")));
136                continue;
137            }
138
139            // Begin non-prose environment
140            if let Some(caps) = BEGIN_ENV_RE.captures(line) {
141                let env_name = caps.get(1).unwrap().as_str().to_string();
142                if Self::is_non_prose_env(&env_name) {
143                    flush_prose(&mut current_prose, &mut regions);
144                    // Check if \end is on the same line
145                    if let Some(end_caps) = END_ENV_RE.captures(line) {
146                        if end_caps.get(1).unwrap().as_str() == env_name {
147                            regions.push(Region::Structure(format!("{line}\n")));
148                            continue;
149                        }
150                    }
151                    in_non_prose_env = Some(env_name);
152                    regions.push(Region::Structure(format!("{line}\n")));
153                    continue;
154                }
155            }
156
157            // Display math \[
158            if DISPLAY_MATH_OPEN.is_match(line) && !DISPLAY_MATH_CLOSE.is_match(line) {
159                flush_prose(&mut current_prose, &mut regions);
160                in_display_math = true;
161                regions.push(Region::Structure(format!("{line}\n")));
162                continue;
163            }
164
165            // Single-line display math \[...\]
166            if DISPLAY_MATH_OPEN.is_match(line) && DISPLAY_MATH_CLOSE.is_match(line) {
167                flush_prose(&mut current_prose, &mut regions);
168                regions.push(Region::Structure(format!("{line}\n")));
169                continue;
170            }
171
172            // Regular prose line
173            if !current_prose.is_empty() {
174                current_prose.push(' ');
175            }
176            current_prose.push_str(line.trim());
177        }
178
179        flush_prose(&mut current_prose, &mut regions);
180        regions
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn preamble_is_structure() {
190        let input = r"\documentclass{article}
191\usepackage{amsmath}
192\begin{document}
193Hello world.
194\end{document}";
195        let regions = LatexParser.parse(input);
196        // First 3 lines are preamble structure (including \begin{document})
197        assert!(matches!(&regions[0], Region::Structure(_)));
198        assert!(matches!(&regions[1], Region::Structure(_)));
199        assert!(matches!(&regions[2], Region::Structure(_)));
200        // "Hello world." is prose
201        let has_prose = regions.iter().any(|r| matches!(r, Region::Prose(_)));
202        assert!(has_prose);
203    }
204
205    #[test]
206    fn equation_preserved() {
207        let input = r"\begin{document}
208Some text here.
209\begin{equation}
210E = mc^2
211\end{equation}
212More text.
213\end{document}";
214        let regions = LatexParser.parse(input);
215        let structure_count = regions
216            .iter()
217            .filter(|r| matches!(r, Region::Structure(_)))
218            .count();
219        // Preamble line + begin{equation} + E=mc^2 + end{equation} + end{document}
220        assert!(structure_count >= 4);
221    }
222
223    #[test]
224    fn comments_preserved() {
225        let input = r"\begin{document}
226% This is a comment
227Some text.
228\end{document}";
229        let regions = LatexParser.parse(input);
230        let comment_region = regions.iter().find(|r| {
231            if let Region::Structure(s) = r {
232                s.contains("% This is a comment")
233            } else {
234                false
235            }
236        });
237        assert!(comment_region.is_some());
238    }
239}