snapper_fmt/parser/
latex.rs1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
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 MINTED_LANG_RE: LazyLock<Regex> =
41 LazyLock::new(|| Regex::new(r"\\begin\{minted\}\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}").unwrap());
42
43static LSTLISTING_LANG_RE: LazyLock<Regex> = LazyLock::new(|| {
45 Regex::new(r"\\begin\{lstlisting\}\s*\[[^\]]*language\s*=\s*([A-Za-z0-9_+.\-]+)").unwrap()
46});
47
48fn 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
57static 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(caps) = SECTION_CMD_RE.captures(line) {
233 flush_prose(&mut current_prose, &mut regions);
234 let prefix = caps.get(1).unwrap().as_str();
235 let title = caps.get(2).unwrap().as_str();
236 let suffix = caps.get(3).unwrap().as_str();
237 regions.push(Region::Structure(prefix.to_string()));
238 if !title.is_empty() {
239 regions.push(Region::Prose(title.to_string()));
240 }
241 regions.push(Region::Structure(format!("{suffix}\n")));
242 continue;
243 }
244
245 if DISPLAY_MATH_OPEN.is_match(line) && !DISPLAY_MATH_CLOSE.is_match(line) {
247 flush_prose(&mut current_prose, &mut regions);
248 in_display_math = true;
249 regions.push(Region::Structure(format!("{line}\n")));
250 continue;
251 }
252
253 if DISPLAY_MATH_OPEN.is_match(line) && DISPLAY_MATH_CLOSE.is_match(line) {
255 flush_prose(&mut current_prose, &mut regions);
256 regions.push(Region::Structure(format!("{line}\n")));
257 continue;
258 }
259
260 if !current_prose.is_empty() {
262 current_prose.push(' ');
263 }
264 current_prose.push_str(line.trim());
265 }
266
267 flush_prose(&mut current_prose, &mut regions);
268 if in_code_env.is_some() {
269 regions.push(Region::Code {
270 lang: code_lang.take(),
271 header: std::mem::take(&mut code_header),
272 body: std::mem::take(&mut code_body),
273 footer: String::new(),
274 });
275 }
276 regions
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn section_command_splits_title_as_prose() {
286 let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody.\n\\end{document}\n";
287 let regions = LatexParser.parse(input);
288 let prose: Vec<_> = regions
289 .iter()
290 .filter_map(|r| match r {
291 Region::Prose(t) => Some(t.as_str()),
292 _ => None,
293 })
294 .collect();
295 assert!(
296 prose.iter().any(|t| t.contains("A long title")),
297 "section title should be prose, got regions: {regions:?}"
298 );
299 assert!(prose.contains(&"Body."));
300 }
301
302 #[test]
303 fn preamble_is_structure() {
304 let input = r"\documentclass{article}
305\usepackage{amsmath}
306\begin{document}
307Hello world.
308\end{document}";
309 let regions = LatexParser.parse(input);
310 assert!(matches!(®ions[0], Region::Structure(_)));
312 assert!(matches!(®ions[1], Region::Structure(_)));
313 assert!(matches!(®ions[2], Region::Structure(_)));
314 let has_prose = regions.iter().any(|r| matches!(r, Region::Prose(_)));
316 assert!(has_prose);
317 }
318
319 #[test]
320 fn equation_preserved() {
321 let input = r"\begin{document}
322Some text here.
323\begin{equation}
324E = mc^2
325\end{equation}
326More text.
327\end{document}";
328 let regions = LatexParser.parse(input);
329 let structure_count = regions
330 .iter()
331 .filter(|r| matches!(r, Region::Structure(_)))
332 .count();
333 assert!(structure_count >= 4);
335 }
336
337 #[test]
338 fn comments_preserved() {
339 let input = r"\begin{document}
340% This is a comment
341Some text.
342\end{document}";
343 let regions = LatexParser.parse(input);
344 let comment_region = regions.iter().find(|r| {
345 if let Region::Structure(s) = r {
346 s.contains("% This is a comment")
347 } else {
348 false
349 }
350 });
351 assert!(comment_region.is_some());
352 }
353}