snapper_fmt/parser/
latex.rs1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region};
5
6static 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(matches!(®ions[0], Region::Structure(_)));
198 assert!(matches!(®ions[1], Region::Structure(_)));
199 assert!(matches!(®ions[2], Region::Structure(_)));
200 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 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}