1use std::path::{Path, PathBuf};
24
25use once_cell::sync::Lazy;
26use regex::Regex;
27
28pub fn find_main_tex(dir: &Path) -> Result<PathBuf, String> {
34 if let Some(filename) = parse_readme_json(dir) {
37 let main_path = dir.join(&filename);
38 if main_path.exists() {
39 return Ok(main_path);
40 }
41 }
42
43 let mut ignored_names: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
52 let readme_xxx = dir.join("00README.XXX");
53 if readme_xxx.exists()
54 && let Ok(content) = std::fs::read_to_string(&readme_xxx)
55 {
56 for line in content.lines() {
57 let parts: Vec<&str> = line.split_whitespace().collect();
58 if parts.len() < 2 {
59 continue;
60 }
61 match parts[1] {
62 "toplevelfile" => {
63 let main_path = dir.join(parts[0]);
64 if main_path.exists() {
65 return Ok(main_path);
66 }
67 },
68 "ignore" => {
69 ignored_names.insert(dir.join(parts[0]));
70 },
71 _ => {},
72 }
73 }
74 }
75
76 let mut tex_files: Vec<PathBuf> = Vec::new();
78 collect_tex_files(dir, &mut tex_files, false);
79 if !ignored_names.is_empty() {
80 tex_files.retain(|p| !ignored_names.contains(p));
81 }
82 let candidates_before_pdf_filter = tex_files.len();
83 tex_files.retain(|p| !is_pdf_magic(p));
84 if tex_files.is_empty() && candidates_before_pdf_filter > 0 {
85 return Err(s(
86 "Fatal:invalid:not_tex_source PDF magic detected in source file (no TeX-format files in archive)",
87 ));
88 }
89 if tex_files.is_empty() {
90 collect_tex_files(dir, &mut tex_files, true);
91 tex_files.retain(|p| !is_pdf_magic(p));
92 if !ignored_names.is_empty() {
93 tex_files.retain(|p| !ignored_names.contains(p));
94 }
95 }
96 if tex_files.is_empty() {
97 return Err(s("No .tex files found in directory"));
98 }
99
100 let mut likelihood: rustc_hash::FxHashMap<PathBuf, f32> = rustc_hash::FxHashMap::default();
102 let mut vetoed: Vec<(PathBuf, PathBuf)> = Vec::new();
107 let mut had_auto_ignore = false;
108
109 for tex_file in &tex_files {
110 if !tex_file.exists() {
111 continue;
112 }
113 let Ok(raw) = std::fs::read(tex_file) else {
114 continue;
115 };
116 let content = String::from_utf8_lossy(&raw);
117 let mut maybe_tex = false;
118 let mut maybe_tex_priority = false;
119 let mut maybe_tex_priority2 = false;
120 let mut determined = false;
121
122 for (lineno, raw_line) in content.lines().enumerate() {
123 let lineno1 = lineno + 1;
124 if lineno1 <= 10
126 && (RE_AUTOIGNORE.is_match(raw_line)
127 || RE_TEXINFO.is_match(raw_line)
128 || RE_AUTOINCLUDE.is_match(raw_line))
129 {
130 likelihood.insert(tex_file.clone(), 0.0);
131 if RE_AUTOIGNORE.is_match(raw_line) {
132 had_auto_ignore = true;
133 }
134 determined = true;
135 break;
136 }
137 if lineno1 <= 12
138 && let Some(cap) = RE_FORMAT_HINT.captures(raw_line)
139 {
140 let fmt = &cap[1];
141 if fmt == "latex209" || fmt == "biglatex" || fmt == "latex" || fmt == "LaTeX" {
142 likelihood.insert(tex_file.clone(), 3.0);
143 } else {
144 likelihood.insert(tex_file.clone(), 1.0);
145 }
146 determined = true;
147 break;
148 }
149 let stripped: std::borrow::Cow<str> = RE_STRIP_COMMENT.replacen(raw_line, 1, "");
153 let line: &str = &stripped;
154
155 if RE_DOCCLASS.is_match(line) {
156 likelihood.insert(tex_file.clone(), 3.0);
157 determined = true;
158 break;
159 }
160 if RE_MAYBE_TEX.is_match(line) {
161 maybe_tex = true;
162 }
163 if let Some(cap) = RE_INPUT_INCLUDE.captures(line) {
165 maybe_tex = true;
166 let mut vetoed_name = cap[1].to_string();
167 if RE_AMSTEX.is_match(&vetoed_name) {
168 likelihood.insert(tex_file.clone(), 2.0);
169 determined = true;
170 break;
171 }
172 if !vetoed_name.contains('.') {
173 vetoed_name = vetoed_name.trim_end().to_string() + ".tex";
174 }
175 let base_dir = tex_file.parent().unwrap_or(dir);
176 vetoed.push((base_dir.join(&vetoed_name), tex_file.clone()));
177 }
178 if RE_END_BYE.is_match(line) {
179 maybe_tex_priority = true;
180 }
181 if RE_END_BYE2.is_match(line) {
182 maybe_tex_priority2 = true;
183 }
184 if RE_MAC_TEX.is_match(line) {
185 likelihood.insert(tex_file.clone(), 1.0);
186 determined = true;
187 break;
188 }
189 if RE_METAFONT.is_match(line) {
190 likelihood.insert(tex_file.clone(), 0.0);
191 determined = true;
192 break;
193 }
194 if RE_BIBTEX.is_match(raw_line) {
195 likelihood.insert(tex_file.clone(), 0.0);
196 determined = true;
197 break;
198 }
199 if RE_UUENCODE.is_match(raw_line) {
200 if maybe_tex_priority {
201 likelihood.insert(tex_file.clone(), 2.0);
202 } else if maybe_tex {
203 likelihood.insert(tex_file.clone(), 1.0);
204 } else {
205 likelihood.insert(tex_file.clone(), 0.0);
206 }
207 determined = true;
208 break;
209 }
210 if RE_WITHDRAWN.is_match(line) {
211 likelihood.insert(tex_file.clone(), 0.0);
212 determined = true;
213 break;
214 }
215 }
216 if !determined {
217 let score = if maybe_tex_priority {
218 2.0
219 } else if maybe_tex_priority2 {
220 1.5
221 } else if maybe_tex {
222 1.0
223 } else {
224 0.0
225 };
226 likelihood.insert(tex_file.clone(), score);
227 }
228 }
229
230 for (vetee, vetoer) in &vetoed {
231 let vetee_score = likelihood.get(vetee).copied().unwrap_or(0.0);
232 let vetoer_score = likelihood.get(vetoer).copied().unwrap_or(0.0);
233 if vetoer_score >= vetee_score {
234 likelihood.remove(vetee);
235 }
236 }
237
238 let mut candidates: Vec<PathBuf> = likelihood
239 .keys()
240 .filter(|f| likelihood[*f] > 0.0)
241 .cloned()
242 .collect();
243 candidates.sort_by(|a, b| likelihood[b].partial_cmp(&likelihood[a]).unwrap());
244
245 if candidates.is_empty() {
246 if had_auto_ignore {
247 let dir_name = dir.file_name().and_then(|s| s.to_str()).unwrap_or_default();
256 let auto_ignore_main = tex_files
257 .iter()
258 .find(|p| {
259 p.file_stem()
260 .and_then(|s| s.to_str())
261 .is_some_and(|stem| stem == dir_name)
262 })
263 .cloned()
264 .or_else(|| tex_files.first().cloned());
265 if let Some(p) = auto_ignore_main {
266 return Ok(p);
267 }
268 }
269 return Err(s("No viable .tex files found in directory"));
270 }
271
272 let max_score = likelihood[&candidates[0]];
273 candidates.retain(|f| (likelihood[f] - max_score).abs() < f32::EPSILON);
274
275 if candidates.len() > 1 {
276 let min_depth = candidates
277 .iter()
278 .map(|f| f.strip_prefix(dir).unwrap_or(f).components().count())
279 .min()
280 .unwrap_or(0);
281 candidates.retain(|f| f.strip_prefix(dir).unwrap_or(f).components().count() == min_depth);
282 }
283
284 if candidates.len() > 1 {
285 let pdf_candidates: Vec<PathBuf> = candidates
286 .iter()
287 .filter(|f| {
288 std::fs::read(f).ok().is_some_and(|raw| {
289 let c = String::from_utf8_lossy(&raw);
290 c.contains("\\includegraphics")
291 && (c.contains(".pdf") || c.contains(".png") || c.contains(".jpg"))
292 })
293 })
294 .cloned()
295 .collect();
296 if !pdf_candidates.is_empty() {
297 candidates = pdf_candidates;
298 }
299 }
300
301 if candidates.len() > 1 {
302 let bbl_candidates: Vec<PathBuf> = candidates
303 .iter()
304 .filter(|f| f.with_extension("bbl").exists())
305 .cloned()
306 .collect();
307 if !bbl_candidates.is_empty() {
308 candidates = bbl_candidates;
309 }
310 }
311
312 if candidates.len() > 1 {
313 let common: Vec<PathBuf> = candidates
314 .iter()
315 .filter(|f| {
316 f.file_name().is_some_and(|n| {
317 let n = n.to_str().unwrap_or("");
318 n == "main.tex" || n == "ms.tex" || n == "paper.tex"
319 })
320 })
321 .cloned()
322 .collect();
323 if !common.is_empty() {
324 candidates = common;
325 }
326 }
327
328 candidates.sort();
329 Ok(
330 candidates
331 .into_iter()
332 .next()
333 .expect("non-empty after filtering"),
334 )
335}
336
337fn s(msg: &str) -> String { msg.to_string() }
342
343fn collect_tex_files(dir: &Path, files: &mut Vec<PathBuf>, fallback: bool) {
348 if let Ok(entries) = std::fs::read_dir(dir) {
349 for entry in entries.flatten() {
350 let path = entry.path();
351 if path.is_dir() {
352 collect_tex_files(&path, files, fallback);
353 } else if !fallback {
354 if path.extension().is_some_and(|e| {
355 let e = e.to_ascii_lowercase();
356 e == "tex" || e == "txt" || e == "ltx"
357 }) {
358 files.push(path);
359 }
360 } else {
361 let ext_opt = path.extension().and_then(|e| e.to_str());
362 let keep = match ext_opt {
363 None => true,
364 Some(ext) => ext.len() >= 4,
365 };
366 if keep {
367 files.push(path);
368 }
369 }
370 }
371 }
372}
373
374pub fn is_pdf_magic(path: &Path) -> bool {
377 let mut buf = [0u8; 5];
378 if let Ok(mut f) = std::fs::File::open(path) {
379 use std::io::Read;
380 if f.read(&mut buf).is_ok_and(|n| n == 5) {
381 return &buf == b"%PDF-";
382 }
383 }
384 false
385}
386
387fn parse_readme_json(dir: &Path) -> Option<String> {
392 let content = std::fs::read_to_string(dir.join("00README.json")).ok()?;
393 let sources_start = content.find("\"sources\"")?;
394 let rest = &content[sources_start..];
395 let arr_start = rest.find('[')?;
396 let arr_end = rest.find(']')?;
397 let arr = &rest[arr_start + 1..arr_end];
398
399 for obj_str in arr.split('}') {
400 if !obj_str.contains("\"toplevel\"") {
401 continue;
402 }
403 if let Some(fn_pos) = obj_str.find("\"filename\"") {
404 let after_key = &obj_str[fn_pos + 10..];
405 let after_key = after_key.trim_start();
406 let after_key = after_key.strip_prefix(':')?;
407 let after_key = after_key.trim_start();
408 let after_key = after_key.strip_prefix('"')?;
409 let mut result = String::new();
410 for ch in after_key.chars() {
411 match ch {
412 '"' => break,
413 '\\' => continue,
414 c => result.push(c),
415 }
416 }
417 if !result.is_empty() {
418 return Some(result);
419 }
420 }
421 }
422 None
423}
424
425static RE_AUTOIGNORE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%auto-ignore").unwrap());
432static RE_TEXINFO: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\input texinfo").unwrap());
433static RE_AUTOINCLUDE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%auto-include").unwrap());
434static RE_FORMAT_HINT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\r?%&(\S+)").unwrap());
435static RE_DOCCLASS: Lazy<Regex> =
436 Lazy::new(|| Regex::new(r"(?:^|\r)\s*\\document(?:style|class)").unwrap());
437static RE_MAYBE_TEX: Lazy<Regex> = Lazy::new(|| {
438 Regex::new(r"(?:^|\r)\s*\\(?:font|magnification|input|def|special|baselineskip|begin)").unwrap()
439});
440static RE_INPUT_INCLUDE: Lazy<Regex> =
441 Lazy::new(|| Regex::new(r"\\(?:input|include)(?:\s+|\{)([^ \}]+)").unwrap());
442static RE_END_BYE: Lazy<Regex> =
443 Lazy::new(|| Regex::new(r"(?:^|\r)\s*\\(?:end|bye)(?:\s|$)").unwrap());
444static RE_END_BYE2: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\(?:end|bye)(?:\s|$)").unwrap());
445static RE_MAC_TEX: Lazy<Regex> =
446 Lazy::new(|| Regex::new(r"\\input *(?:harv|lanl)mac|\\input\s+phyzzx").unwrap());
447static RE_METAFONT: Lazy<Regex> = Lazy::new(|| Regex::new(r"beginchar\(").unwrap());
448static RE_BIBTEX: Lazy<Regex> =
449 Lazy::new(|| Regex::new(r"(?i)(?:^|\r)@(?:book|article|inbook|unpublished)\{").unwrap());
450static RE_UUENCODE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^begin \d{1,4}\s+\S+\r?$").unwrap());
451static RE_WITHDRAWN: Lazy<Regex> =
452 Lazy::new(|| Regex::new(r"paper deliberately replaced by what little").unwrap());
453static RE_AMSTEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^amstex$").unwrap());
454static RE_STRIP_COMMENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"%[^\r]*").unwrap());
457
458#[cfg(test)]
459mod tests {
460 use tempfile::tempdir;
461
462 use super::*;
463
464 fn write(dir: &Path, name: &str, body: &str) { std::fs::write(dir.join(name), body).unwrap(); }
465
466 #[test]
467 fn picks_documentclass_over_input_only_file() {
468 let d = tempdir().unwrap();
469 write(
470 d.path(),
471 "main.tex",
472 "\\documentclass{article}\n\\begin{document}x\\end{document}",
473 );
474 write(d.path(), "intro.tex", "\\input{intro_body}\n");
475 let pick = find_main_tex(d.path()).unwrap();
476 assert_eq!(pick.file_name().unwrap(), "main.tex");
477 }
478
479 #[test]
480 fn errors_when_directory_has_no_tex() {
481 let d = tempdir().unwrap();
482 let err = find_main_tex(d.path()).unwrap_err();
483 assert!(err.contains("No .tex files"));
484 }
485
486 #[test]
487 fn prefers_main_over_other_documentclass_files() {
488 let d = tempdir().unwrap();
489 write(d.path(), "draft.tex", "\\documentclass{article}\n");
490 write(d.path(), "main.tex", "\\documentclass{article}\n");
491 let pick = find_main_tex(d.path()).unwrap();
492 assert_eq!(pick.file_name().unwrap(), "main.tex");
493 }
494
495 #[test]
496 fn readme_xxx_toplevelfile_directive_short_circuits() {
497 let d = tempdir().unwrap();
498 write(d.path(), "draft.tex", "\\documentclass{article}\n");
501 write(d.path(), "main.tex", "\\documentclass{article}\n");
502 write(d.path(), "00README.XXX", "draft.tex toplevelfile\n");
503 let pick = find_main_tex(d.path()).unwrap();
504 assert_eq!(pick.file_name().unwrap(), "draft.tex");
505 }
506
507 #[test]
508 fn readme_xxx_ignore_directive_excludes_candidate() {
509 let d = tempdir().unwrap();
510 write(d.path(), "old.tex", "\\documentclass{article}\n");
515 write(d.path(), "main.tex", "\\documentclass{article}\n");
516 write(d.path(), "00README.XXX", "main.tex ignore\n");
517 let pick = find_main_tex(d.path()).unwrap();
518 assert_eq!(pick.file_name().unwrap(), "old.tex");
519 }
520
521 #[test]
522 fn readme_xxx_mixed_directives() {
523 let d = tempdir().unwrap();
524 write(d.path(), "intro.tex", "\\documentclass{article}\n");
525 write(d.path(), "paper.tex", "\\documentclass{article}\n");
526 write(d.path(), "junk.tex", "\\documentclass{article}\n");
527 write(
531 d.path(),
532 "00README.XXX",
533 "junk.tex ignore\npaper.tex toplevelfile\n",
534 );
535 let pick = find_main_tex(d.path()).unwrap();
536 assert_eq!(pick.file_name().unwrap(), "paper.tex");
537 }
538}