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> =
45 LazyLock::new(|| Regex::new(r"\\begin\{lstlisting\}\s*\[[^\]]*language\s*=\s*([A-Za-z0-9_+.\-]+)").unwrap());
46
47fn 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
56static 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(matches!(®ions[0], Region::Structure(_)));
311 assert!(matches!(®ions[1], Region::Structure(_)));
312 assert!(matches!(®ions[2], Region::Structure(_)));
313 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 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}