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 SECTION_CMD_RE.is_match(line) {
236 flush_prose(&mut current_prose, &mut regions);
237 regions.push(Region::Structure(format!("{line}\n")));
238 continue;
239 }
240
241 if DISPLAY_MATH_OPEN.is_match(line) && !DISPLAY_MATH_CLOSE.is_match(line) {
243 flush_prose(&mut current_prose, &mut regions);
244 in_display_math = true;
245 regions.push(Region::Structure(format!("{line}\n")));
246 continue;
247 }
248
249 if DISPLAY_MATH_OPEN.is_match(line) && DISPLAY_MATH_CLOSE.is_match(line) {
251 flush_prose(&mut current_prose, &mut regions);
252 regions.push(Region::Structure(format!("{line}\n")));
253 continue;
254 }
255
256 if !current_prose.is_empty() {
258 current_prose.push(' ');
259 }
260 current_prose.push_str(line.trim());
261 }
262
263 flush_prose(&mut current_prose, &mut regions);
264 if in_code_env.is_some() {
265 regions.push(Region::Code {
266 lang: code_lang.take(),
267 header: std::mem::take(&mut code_header),
268 body: std::mem::take(&mut code_body),
269 footer: String::new(),
270 });
271 }
272 regions
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn section_command_title_is_structure_not_prose() {
282 let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody.\n\\end{document}\n";
283 let regions = LatexParser.parse(input);
284 assert!(
285 regions.iter().any(|r| matches!(
286 r,
287 Region::Structure(s) if s.contains(r"\section{A long title. With two sentences.}")
288 )),
289 "full section line must be Structure, got: {regions:?}"
290 );
291 assert!(
292 !regions
293 .iter()
294 .any(|r| matches!(r, Region::Prose(p) if p.contains("A long title"))),
295 "section title must not be Prose: {regions:?}"
296 );
297 let prose: Vec<_> = regions
298 .iter()
299 .filter_map(|r| match r {
300 Region::Prose(t) => Some(t.as_str()),
301 _ => None,
302 })
303 .collect();
304 assert!(prose.contains(&"Body."));
305 }
306
307 #[test]
308 fn multi_sentence_section_title_stays_one_line() {
309 use crate::format::Format;
310 use crate::{FormatConfig, format_text};
311
312 let input = "\\begin{document}\n\\section{A long title. With two sentences.}\nBody text here. More body.\n\\end{document}\n";
313 let cfg = FormatConfig {
314 format: Format::Latex,
315 ..Default::default()
316 };
317 let out = format_text(input, &cfg).unwrap();
318 assert!(
319 out.contains("\\section{A long title. With two sentences.}"),
320 "section title must stay one line, got:\n{out}"
321 );
322 assert!(
323 !out.contains("\\section{A long title.\n"),
324 "must not reflow mid-title inside braces:\n{out}"
325 );
326 assert_eq!(format_text(&out, &cfg).unwrap(), out);
327 }
328
329 #[test]
330 fn preamble_is_structure() {
331 let input = r"\documentclass{article}
332\usepackage{amsmath}
333\begin{document}
334Hello world.
335\end{document}";
336 let regions = LatexParser.parse(input);
337 assert!(matches!(®ions[0], Region::Structure(_)));
339 assert!(matches!(®ions[1], Region::Structure(_)));
340 assert!(matches!(®ions[2], Region::Structure(_)));
341 let has_prose = regions.iter().any(|r| matches!(r, Region::Prose(_)));
343 assert!(has_prose);
344 }
345
346 #[test]
347 fn equation_preserved() {
348 let input = r"\begin{document}
349Some text here.
350\begin{equation}
351E = mc^2
352\end{equation}
353More text.
354\end{document}";
355 let regions = LatexParser.parse(input);
356 let structure_count = regions
357 .iter()
358 .filter(|r| matches!(r, Region::Structure(_)))
359 .count();
360 assert!(structure_count >= 4);
362 }
363
364 #[test]
365 fn comments_preserved() {
366 let input = r"\begin{document}
367% This is a comment
368Some text.
369\end{document}";
370 let regions = LatexParser.parse(input);
371 let comment_region = regions.iter().find(|r| {
372 if let Region::Structure(s) = r {
373 s.contains("% This is a comment")
374 } else {
375 false
376 }
377 });
378 assert!(comment_region.is_some());
379 }
380}