Skip to main content

latexml/
main_tex.rs

1//! Main `.tex` discovery for directory inputs.
2//!
3//! Equivalent to the binary's `--whatsin=directory` mode: given a
4//! source directory, return the path of the file the converter should
5//! treat as the top-level entrypoint. Lifted from
6//! `bin/latexml_oxide.rs` so embedders (e.g. `ar5iv-editor`,
7//! `cortex_worker`) can run the same heuristic without shelling out.
8//!
9//! Detection order:
10//!  1. `00README.json` "sources" entry with `usage == "toplevel"` (modern arXiv format).
11//!  2. `00README.XXX` line tagged `toplevelfile` (legacy arXiv). Lines tagged `ignore` exclude the
12//!     named file from later heuristic scanning (Perl `Pack.pm::detect_source` `unlink`s them; we
13//!     filter the candidate list instead — safer if the directory isn't a sandbox).
14//!  3. Pack.pm-derived likelihood scoring across every `.tex` / `.txt` / `.ltx` (and, as a
15//!     fallback, every long-extensioned or extension-less file). Files vetoed by `\input` /
16//!     `\include` references are excluded; `\documentclass` / `\documentstyle` and Mac-classic
17//!     markers boost the score; bibtex / metafont / %auto-ignore / withdrawal sentinels disqualify.
18//!
19//! Returns the absolute path of the chosen entry as a `PathBuf`. The
20//! `Err` arm carries a Perl-canonical `Fatal:invalid:not_tex_source`
21//! style message (matching what the binary printed before this lift).
22
23use std::path::{Path, PathBuf};
24
25use once_cell::sync::Lazy;
26use regex::Regex;
27
28/// Discover the main `.tex` file inside `dir`, mirroring the binary's
29/// `--whatsin=directory` heuristic. The returned path is rooted at
30/// `dir`. Errors carry a human-readable diagnostic on `Fatal:` failure
31/// modes (PDF mis-named as TeX, only `%auto-ignore` files, no TeX at
32/// all).
33pub fn find_main_tex(dir: &Path) -> Result<PathBuf, String> {
34  // Phase I.1: Check 00README.json (2025 arXiv format)
35  // Format: { "sources": [{"filename": "main.tex", "usage": "toplevel"}, ...] }
36  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  // Phase I.1.2: Check 00README.XXX (legacy arXiv format).
44  // Two directive kinds supported (Perl Pack.pm L82-97):
45  //   `<name> toplevelfile` → shortcut, return directly.
46  //   `<name> ignore`       → exclude `<name>` from heuristic
47  //                            candidate scanning below.
48  // Perl additionally `unlink`s the ignored path; we instead carry
49  // the names through as a Phase-I.2 filter set — safer (no
50  // filesystem mutation in case the directory isn't a sandbox).
51  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  // Phase I.2: Heuristic detection (ported from arXiv::FileGuess via Pack.pm)
77  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  // Score each file: likelihood 0-3 (Perl: Main_TeX_likelihood)
101  let mut likelihood: rustc_hash::FxHashMap<PathBuf, f32> = rustc_hash::FxHashMap::default();
102  // (vetoed_path, vetoer_path) — the veto is honored at filtering
103  // time only when the vetoer's score >= the vetee's. Prevents a
104  // 2-line wrapper file (e.g. `\input{main}`) from removing the
105  // documentclass-bearing main.tex. Witness 2307.13586.
106  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      // Perl L117-120: early-line checks (first 10-12 lines)
125      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      // Perl L128: strip ONE `%`-comment up to the next `\r`. `\r`-aware
150      // so bare-`\r` line-ended files (read as one big "line" in Perl
151      // because `$/=\n`) preserve subsequent `\r\documentclass` chunks.
152      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      // Perl L133-148: \input/\include → veto the included file
164      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      // Perl-faithful: process %auto-ignore sources as normal (the
248      // `%` is a comment, the rest is empty → empty XML output, no
249      // Fatal). Witness: 2307.10758 (12-byte `%auto-ignore` source) —
250      // Perl reports "Conversion complete: No obvious problems"; the
251      // old Rust path turned 90 wp4 corpus entries into hard
252      // failures. Cortex_worker has a sibling fix; both must stay in
253      // sync. Prefer the dirname-matching .tex (arxiv convention
254      // `<id>/<id>.tex`), else the first available.
255      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  // OXIDIZED_DESIGN #132 (surpass-perl): the `.bbl`-sibling heuristic runs
285  // BEFORE the pdf-`\includegraphics` heuristic — Perl (Pack.pm L197-204)
286  // runs them in the opposite order. arXiv requires the compiled `<main>.bbl`
287  // to be bundled (BibTeX is not re-run), so a candidate with a matching
288  // `.bbl` is the single strongest fingerprint of the real top-level file.
289  // Perl's pdf heuristic, run first, silently eliminates a true main that
290  // delegates all its figures to `\input`-ed section files (hence carries no
291  // direct `\includegraphics`) whenever a shipped class template / how-to /
292  // supplement DOES contain an example `\includegraphics{fig.png}`; the `.bbl`
293  // tie-break that would have rescued it never gets to run. Ordering `.bbl`
294  // first fixes that class. Witnesses (all SHARED-FAILURE vs production Perl,
295  // html_feedback autotex issues): 2407.05010 (#1721, IEEEtran how-to),
296  // 2409.06957 (#6100, ICLR template), 2409.02543 (#5867, supp.tex),
297  // 2406.08688 (#5476, IEEEtran template), 2310.02368 (#4156, IEEE template),
298  // 2505.05625 (#4067, supplementary.tex), 2410.12672 (#2369, ICLR template),
299  // 2410.01562 (#2224, ICASSP template), 2401.17263 (#442, ICML example_paper),
300  // 2403.17719 (#859, rebuttal.tex). When >1 candidate carries a `.bbl` (e.g.
301  // 2506.05564, 2401.07129) the set survives and the later heuristics
302  // (pdf-include, common-name, alphabetical) still discriminate as before.
303  if candidates.len() > 1 {
304    let bbl_candidates: Vec<PathBuf> = candidates
305      .iter()
306      .filter(|f| f.with_extension("bbl").exists())
307      .cloned()
308      .collect();
309    if !bbl_candidates.is_empty() {
310      candidates = bbl_candidates;
311    }
312  }
313
314  if candidates.len() > 1 {
315    let pdf_candidates: Vec<PathBuf> = candidates
316      .iter()
317      .filter(|f| has_pdftex_marker(f))
318      .cloned()
319      .collect();
320    if !pdf_candidates.is_empty() {
321      candidates = pdf_candidates;
322    }
323  }
324
325  if candidates.len() > 1 {
326    let common: Vec<PathBuf> = candidates
327      .iter()
328      .filter(|f| {
329        f.file_name().is_some_and(|n| {
330          let n = n.to_str().unwrap_or("");
331          n == "main.tex" || n == "ms.tex" || n == "paper.tex"
332        })
333      })
334      .cloned()
335      .collect();
336    if !common.is_empty() {
337      candidates = common;
338    }
339  }
340
341  candidates.sort();
342  Ok(
343    candidates
344      .into_iter()
345      .next()
346      .expect("non-empty after filtering"),
347  )
348}
349
350/// Ordered list of top-level `.tex` files: the main document first, then any
351/// **Supplementary-Material** documents that ship alongside it. `find_main_tex`
352/// returns element 0 of this list; embedders that want to convert the whole
353/// submission (main + supplements, appended in order — arXiv's multi-top-level
354/// model, [`submit_legacy_differences`]) drive off the full list.
355///
356/// Detection is deliberately **precision-first / template-safe**. A supplement
357/// is admitted only when it is a `\documentclass` file that (a) ships its own
358/// matching `.bbl` — arXiv's canonical `ms.tex`+`ms.bbl` / `supplement.tex`+
359/// `supplement.bbl` fingerprint, which bundled class *templates* do not have —
360/// and (b) self-identifies as supplementary by `\title` or filename
361/// (`supplement`/`supporting`/`supplemental`/`appendix`/`SI`/`SM`). Both gates
362/// together keep a messy bundle's template from ever being mistaken for a
363/// second document. An explicit `00README` listing two or more top-level files
364/// overrides the heuristic (author intent), still ordered main-first.
365///
366/// Conservative residual: a supplement bundled *without* its own `.bbl`, or one
367/// that does not self-identify, is not auto-detected.
368///
369/// [`submit_legacy_differences`]: https://info.arxiv.org/help/submit_legacy_differences.html
370pub fn find_top_level_texs(dir: &Path) -> Result<Vec<PathBuf>, String> {
371  // An explicit multi-file 00README designation wins over the heuristic.
372  let explicit = explicit_top_levels(dir);
373  if explicit.len() >= 2 {
374    return Ok(order_main_first(explicit, dir));
375  }
376  let main = find_main_tex(dir)?;
377  // The set of `.bbl`-backed top-level documents at the main's depth — arXiv's
378  // multi-top-level fingerprint. Include `main` even if it ships no `.bbl`
379  // (inline bibliography) so a `.bbl`-backed supplement can still attach to it.
380  let mut set = top_level_bbl_docs(dir, &main);
381  if !set.contains(&main) {
382    set.push(main.clone());
383  }
384  // Split into the single true main (the lone non-supplement) and supplements.
385  // This is done over the whole set rather than trusting `find_main_tex`'s
386  // alphabetical tie-break, which can pick a supplement (e.g. `Appendix.tex`
387  // sorts before `Note.tex`) when two `.bbl` documents are otherwise level.
388  let (mut supps, mains): (Vec<PathBuf>, Vec<PathBuf>) =
389    set.into_iter().partition(|f| is_supplement(f));
390  if mains.len() == 1 && !supps.is_empty() {
391    supps.sort();
392    supps.dedup();
393    let mut tops = mains;
394    tops.extend(supps);
395    return Ok(tops);
396  }
397  // No clean 1-main + supplements split → single-file (unchanged behavior).
398  Ok(vec![main])
399}
400
401// ---------------------------------------------------------------------------
402// Internal helpers.
403// ---------------------------------------------------------------------------
404
405fn s(msg: &str) -> String { msg.to_string() }
406
407/// All files a `00README.json`/`00README.XXX` explicitly marks as top-level, in
408/// listed order, restricted to those that exist (main-first ordering applied by
409/// the caller). Empty when there is no explicit designation.
410fn explicit_top_levels(dir: &Path) -> Vec<PathBuf> {
411  let mut out: Vec<PathBuf> = Vec::new();
412  for name in parse_readme_json_all(dir) {
413    let p = dir.join(&name);
414    if p.exists() && !out.contains(&p) {
415      out.push(p);
416    }
417  }
418  let readme_xxx = dir.join("00README.XXX");
419  if let Ok(content) = std::fs::read_to_string(&readme_xxx) {
420    for line in content.lines() {
421      let parts: Vec<&str> = line.split_whitespace().collect();
422      if parts.len() >= 2 && parts[1] == "toplevelfile" {
423        let p = dir.join(parts[0]);
424        if p.exists() && !out.contains(&p) {
425          out.push(p);
426        }
427      }
428    }
429  }
430  out
431}
432
433/// The set of `.bbl`-backed top-level documents at `main`'s depth: `\document`-
434/// class files that carry their own sibling `.bbl` (arXiv's own top-level
435/// fingerprint, which bundled templates lack), excluding `%auto-ignore` and
436/// `00README` `ignore` files. `main` itself is included when it qualifies.
437fn top_level_bbl_docs(dir: &Path, main: &Path) -> Vec<PathBuf> {
438  let main_depth = main.strip_prefix(dir).unwrap_or(main).components().count();
439  let ignored = readme_ignored(dir);
440  let mut tex_files: Vec<PathBuf> = Vec::new();
441  collect_tex_files(dir, &mut tex_files, false);
442  tex_files
443    .into_iter()
444    .filter(|f| f.strip_prefix(dir).unwrap_or(f).components().count() == main_depth)
445    .filter(|f| !ignored.contains(f))
446    .filter(|f| !has_auto_ignore(f))
447    .filter(|f| f.with_extension("bbl").exists())
448    .filter(|f| file_has_documentclass(f))
449    .collect()
450}
451
452/// Files a `00README.XXX` marks `ignore` (Perl `Pack.pm` `unlink`s them; we
453/// exclude by set — see `find_main_tex`).
454fn readme_ignored(dir: &Path) -> rustc_hash::FxHashSet<PathBuf> {
455  let mut ignored = rustc_hash::FxHashSet::default();
456  if let Ok(content) = std::fs::read_to_string(dir.join("00README.XXX")) {
457    for line in content.lines() {
458      let parts: Vec<&str> = line.split_whitespace().collect();
459      if parts.len() >= 2 && parts[1] == "ignore" {
460        ignored.insert(dir.join(parts[0]));
461      }
462    }
463  }
464  ignored
465}
466
467/// Whether one of the file's first 10 lines carries the `%auto-ignore` marker
468/// (arXiv: keep in the bundle, do not process — `submit_legacy_differences`).
469fn has_auto_ignore(path: &Path) -> bool {
470  let Ok(raw) = std::fs::read(path) else {
471    return false;
472  };
473  String::from_utf8_lossy(&raw)
474    .lines()
475    .take(10)
476    .any(|l| RE_AUTOIGNORE.is_match(l))
477}
478
479/// Order a set of top-level files main-first: any file that looks like a
480/// supplement goes last (arXiv-alphanumeric within each group); among the
481/// non-supplements a `main`/`ms`/`paper` common name is promoted to the front.
482fn order_main_first(mut files: Vec<PathBuf>, _dir: &Path) -> Vec<PathBuf> {
483  files.sort();
484  files.dedup();
485  let (supps, mut mains): (Vec<PathBuf>, Vec<PathBuf>) =
486    files.into_iter().partition(|f| is_supplement(f));
487  if let Some(pos) = mains.iter().position(|f| is_common_main_name(f)) {
488    let m = mains.remove(pos);
489    mains.insert(0, m);
490  }
491  mains.extend(supps);
492  mains
493}
494
495/// Whether the file names itself `main.tex` / `ms.tex` / `paper.tex` (the same
496/// common-name set the single-file heuristic prefers).
497fn is_common_main_name(path: &Path) -> bool {
498  path
499    .file_name()
500    .and_then(|n| n.to_str())
501    .is_some_and(|n| n == "main.tex" || n == "ms.tex" || n == "paper.tex")
502}
503
504/// A top-level `.tex` reads as Supplementary Material when its filename or its
505/// `\title` announces it (`supplement`/`supporting`/`supplemental`/`appendix`/
506/// delimited `SI`/`SM`). Used only among *already* `.bbl`-backed sibling
507/// documents, so this classifies main-vs-supplement; it never promotes a
508/// template into a document.
509fn is_supplement(path: &Path) -> bool {
510  if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
511    && RE_SUPP_NAME.is_match(stem)
512  {
513    return true;
514  }
515  if let Some(title) = first_title(path)
516    && RE_SUPP_TITLE.is_match(&title)
517  {
518    return true;
519  }
520  false
521}
522
523/// True when the file defines a `\documentclass`/`\documentstyle` (i.e. is a
524/// top-level document, not an `\input`-ed fragment).
525fn file_has_documentclass(path: &Path) -> bool {
526  let Ok(raw) = std::fs::read(path) else {
527    return false;
528  };
529  let content = String::from_utf8_lossy(&raw);
530  content.lines().any(|l| {
531    let stripped = RE_STRIP_COMMENT.replacen(l, 1, "");
532    RE_DOCCLASS.is_match(&stripped)
533  })
534}
535
536/// The text of the first non-commented `\title{…}` / `\icmltitle{…}` in the
537/// file (up to ~120 chars), for supplement classification.
538fn first_title(path: &Path) -> Option<String> {
539  let raw = std::fs::read(path).ok()?;
540  let content = String::from_utf8_lossy(&raw);
541  // Strip `%`-comments line-by-line so a commented template placeholder title
542  // (`%% \title{Title}`) does not count. Witness 2402.13498 (elsarticle).
543  let stripped: String = content
544    .lines()
545    .map(|l| RE_STRIP_COMMENT.replacen(l, 1, "").into_owned())
546    .collect::<Vec<_>>()
547    .join("\n");
548  RE_TITLE_ARG
549    .captures(&stripped)
550    .map(|c| c[1].chars().take(120).collect())
551}
552
553/// Parse `00README.json` and return the filenames of **every** `usage ==
554/// "toplevel"` source, in document order. `parse_readme_json` returns only the
555/// first; this drives multi-top-level detection.
556fn parse_readme_json_all(dir: &Path) -> Vec<String> {
557  let mut out = Vec::new();
558  let Ok(content) = std::fs::read_to_string(dir.join("00README.json")) else {
559    return out;
560  };
561  let Some(sources_start) = content.find("\"sources\"") else {
562    return out;
563  };
564  let rest = &content[sources_start..];
565  let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) else {
566    return out;
567  };
568  let arr = &rest[arr_start + 1..arr_end];
569  for obj_str in arr.split('}') {
570    if !obj_str.contains("\"toplevel\"") {
571      continue;
572    }
573    if let Some(fn_pos) = obj_str.find("\"filename\"") {
574      let after = obj_str[fn_pos + 10..].trim_start();
575      if let Some(after) = after.strip_prefix(':') {
576        let after = after.trim_start();
577        if let Some(after) = after.strip_prefix('"') {
578          let name: String = after
579            .chars()
580            .take_while(|&c| c != '"')
581            .filter(|&c| c != '\\')
582            .collect();
583          if !name.is_empty() {
584            out.push(name);
585          }
586        }
587      }
588    }
589  }
590  out
591}
592
593// Perl Pack.pm L25 TEX_EXT = qr/\.(?:[tT](:?[eE][xX]|[xX][tT])|ltx|LTX)$/
594// → .tex, .txt, .ltx (case-insensitive). The `fallback` arm matches Perl
595// Pack/Dir.pm L47: `!/\./ || /\.[^.]{4,}$/` — extension-less or extension
596// ≥4 chars, used when nothing TeX-shaped surfaces in the strict pass.
597fn collect_tex_files(dir: &Path, files: &mut Vec<PathBuf>, fallback: bool) {
598  if let Ok(entries) = std::fs::read_dir(dir) {
599    for entry in entries.flatten() {
600      let path = entry.path();
601      if path.is_dir() {
602        collect_tex_files(&path, files, fallback);
603      } else if !fallback {
604        if path.extension().is_some_and(|e| {
605          let e = e.to_ascii_lowercase();
606          e == "tex" || e == "txt" || e == "ltx"
607        }) {
608          files.push(path);
609        }
610      } else {
611        let ext_opt = path.extension().and_then(|e| e.to_str());
612        let keep = match ext_opt {
613          None => true,
614          Some(ext) => ext.len() >= 4,
615        };
616        if keep {
617          files.push(path);
618        }
619      }
620    }
621  }
622}
623
624// Faithful port of Perl `Pack.pm::heuristic_check_for_pdftex` (L222-241):
625// a file counts as a pdf-include candidate only when some line carries
626// `\includegraphics{...ext}` with the raster/pdf extension INSIDE the
627// argument (`\includegraphics[^%]*\.(?:pdf|png|gif|jpg)\s?\}`, case-insensitive,
628// not preceded by a `%`), or a `\pdfoutput=1` marker. Perl's `$pdfoutput_checks`
629// counter clamps at 0, so its `>= 0` guard is always true and the `\pdfoutput`
630// probe effectively scans every line — we mirror that (no first-N-lines cap).
631//
632// The earlier Rust port used a loose whole-file
633// `contains("\\includegraphics") && contains(".png"|".pdf"|".jpg")`, which
634// FALSE-POSITIVES on class templates whose `\includegraphics` examples are
635// extensionless (`{icml_numpapers}`) or `.eps` (`{egfigure.eps}`) yet mention
636// a raster extension elsewhere (prose/comments). Because the pdf heuristic runs
637// before the `.bbl` tie-break, that false positive eliminated the true
638// figure-delegating `main.tex`: 2401.17263 (#442, example_paper.tex) and
639// 2403.17719 (#859, rebuttal.tex) were mis-picked by Rust though Perl picks
640// their `main.tex`. Argument-anchoring restores parity.
641fn has_pdftex_marker(path: &Path) -> bool {
642  let Ok(raw) = std::fs::read(path) else {
643    return false;
644  };
645  let content = String::from_utf8_lossy(&raw);
646  content
647    .lines()
648    .any(|line| RE_PDF_INCLUDE.is_match(line) || RE_PDFOUTPUT.is_match(line))
649}
650
651// Skip files whose magic bytes identify them as PDF (e.g. arXiv source
652// archives that contain a PDF mis-named with a `.tex` extension).
653pub fn is_pdf_magic(path: &Path) -> bool {
654  let mut buf = [0u8; 5];
655  if let Ok(mut f) = std::fs::File::open(path) {
656    use std::io::Read;
657    if f.read(&mut buf).is_ok_and(|n| n == 5) {
658      return &buf == b"%PDF-";
659    }
660  }
661  false
662}
663
664/// Parse `00README.json` in `dir` and return the "filename" of the
665/// toplevel source. Perl Pack.pm L68-80: scans `sources[]` for the
666/// entry tagged `usage == "toplevel"`. Minimal hand-rolled JSON
667/// scanner — we don't pull a full JSON dep just for this.
668fn parse_readme_json(dir: &Path) -> Option<String> { parse_readme_json_all(dir).into_iter().next() }
669
670// ---------------------------------------------------------------------------
671// Pre-compiled regexes used by `find_main_tex`. Parking these as module-
672// level `Lazy<Regex>` keeps a single instance per process and avoids the
673// per-call recompile that nesting them inside the function caused.
674// ---------------------------------------------------------------------------
675
676static RE_AUTOIGNORE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%auto-ignore").unwrap());
677static RE_TEXINFO: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\input texinfo").unwrap());
678static RE_AUTOINCLUDE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%auto-include").unwrap());
679static RE_FORMAT_HINT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\r?%&(\S+)").unwrap());
680static RE_DOCCLASS: Lazy<Regex> =
681  Lazy::new(|| Regex::new(r"(?:^|\r)\s*\\document(?:style|class)").unwrap());
682static RE_MAYBE_TEX: Lazy<Regex> = Lazy::new(|| {
683  Regex::new(r"(?:^|\r)\s*\\(?:font|magnification|input|def|special|baselineskip|begin)").unwrap()
684});
685static RE_INPUT_INCLUDE: Lazy<Regex> =
686  Lazy::new(|| Regex::new(r"\\(?:input|include)(?:\s+|\{)([^ \}]+)").unwrap());
687static RE_END_BYE: Lazy<Regex> =
688  Lazy::new(|| Regex::new(r"(?:^|\r)\s*\\(?:end|bye)(?:\s|$)").unwrap());
689static RE_END_BYE2: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\(?:end|bye)(?:\s|$)").unwrap());
690static RE_MAC_TEX: Lazy<Regex> =
691  Lazy::new(|| Regex::new(r"\\input *(?:harv|lanl)mac|\\input\s+phyzzx").unwrap());
692static RE_METAFONT: Lazy<Regex> = Lazy::new(|| Regex::new(r"beginchar\(").unwrap());
693static RE_BIBTEX: Lazy<Regex> =
694  Lazy::new(|| Regex::new(r"(?i)(?:^|\r)@(?:book|article|inbook|unpublished)\{").unwrap());
695static RE_UUENCODE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^begin \d{1,4}\s+\S+\r?$").unwrap());
696static RE_WITHDRAWN: Lazy<Regex> =
697  Lazy::new(|| Regex::new(r"paper deliberately replaced by what little").unwrap());
698static RE_AMSTEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^amstex$").unwrap());
699// Perl `heuristic_check_for_pdftex` pdf-include probe: an `\includegraphics`
700// whose argument names a raster/pdf image (case-insensitive), not behind a `%`.
701static RE_PDF_INCLUDE: Lazy<Regex> =
702  Lazy::new(|| Regex::new(r"(?i)^[^%]*\\includegraphics[^%]*\.(?:pdf|png|gif|jpg)\s?\}").unwrap());
703// Perl `\pdfoutput(?:\s+)?=(?:\s+)?1` marker, not behind a `%`.
704static RE_PDFOUTPUT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^%]*\\pdfoutput\s*=\s*1").unwrap());
705// Supplementary-Material detection (find_top_level_texs). Filename tokens
706// (`paper_SI`, `supplement`, `z_SI_renamed`, `Supplemental_Materials`) — the
707// short `si`/`sm` forms only as delimited tokens so they can't match a substring.
708static RE_SUPP_NAME: Lazy<Regex> = Lazy::new(|| {
709  Regex::new(r"(?i)(^|[_-])(supp(lement(ary|al)?)?|supporting|supplemental|appendix|si|sm)([_-]|$)")
710    .unwrap()
711});
712// Supplement `\title` text ("Supplementary Information for …", "SUPPLEMENTAL
713// MATERIALS", "Supporting Information", "Supplemental Materials for:").
714static RE_SUPP_TITLE: Lazy<Regex> = Lazy::new(|| {
715  Regex::new(r"(?i)(supplement|supporting\s+information|supplemental|appendix)").unwrap()
716});
717// First braced argument of a `\title`/`\icmltitle` (comments already stripped).
718static RE_TITLE_ARG: Lazy<Regex> =
719  Lazy::new(|| Regex::new(r"\\(?:icml)?title\s*(?:\[[^\]]*\])?\s*\{([^}]*)").unwrap());
720// Strip a single `%`-comment, stopping at the next `\r` so bare-`\r`
721// line-ended files (Mac classic) preserve post-comment `\documentclass`.
722static RE_STRIP_COMMENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"%[^\r]*").unwrap());
723
724#[cfg(test)]
725mod tests {
726  use tempfile::tempdir;
727
728  use super::*;
729
730  fn write(dir: &Path, name: &str, body: &str) { std::fs::write(dir.join(name), body).unwrap(); }
731
732  #[test]
733  fn picks_documentclass_over_input_only_file() {
734    let d = tempdir().unwrap();
735    write(
736      d.path(),
737      "main.tex",
738      "\\documentclass{article}\n\\begin{document}x\\end{document}",
739    );
740    write(d.path(), "intro.tex", "\\input{intro_body}\n");
741    let pick = find_main_tex(d.path()).unwrap();
742    assert_eq!(pick.file_name().unwrap(), "main.tex");
743  }
744
745  #[test]
746  fn errors_when_directory_has_no_tex() {
747    let d = tempdir().unwrap();
748    let err = find_main_tex(d.path()).unwrap_err();
749    assert!(err.contains("No .tex files"));
750  }
751
752  #[test]
753  fn prefers_main_over_other_documentclass_files() {
754    let d = tempdir().unwrap();
755    write(d.path(), "draft.tex", "\\documentclass{article}\n");
756    write(d.path(), "main.tex", "\\documentclass{article}\n");
757    let pick = find_main_tex(d.path()).unwrap();
758    assert_eq!(pick.file_name().unwrap(), "main.tex");
759  }
760
761  #[test]
762  fn readme_xxx_toplevelfile_directive_short_circuits() {
763    let d = tempdir().unwrap();
764    // Two candidate documents — main.tex would normally win,
765    // but the 00README directive points at draft.tex.
766    write(d.path(), "draft.tex", "\\documentclass{article}\n");
767    write(d.path(), "main.tex", "\\documentclass{article}\n");
768    write(d.path(), "00README.XXX", "draft.tex toplevelfile\n");
769    let pick = find_main_tex(d.path()).unwrap();
770    assert_eq!(pick.file_name().unwrap(), "draft.tex");
771  }
772
773  #[test]
774  fn readme_xxx_ignore_directive_excludes_candidate() {
775    let d = tempdir().unwrap();
776    // Without a directive, `main.tex` would tie with `old.tex`
777    // and the "common name" heuristic prefers `main.tex`. With
778    // an `ignore` directive on main.tex, the heuristic falls back
779    // to `old.tex`.
780    write(d.path(), "old.tex", "\\documentclass{article}\n");
781    write(d.path(), "main.tex", "\\documentclass{article}\n");
782    write(d.path(), "00README.XXX", "main.tex ignore\n");
783    let pick = find_main_tex(d.path()).unwrap();
784    assert_eq!(pick.file_name().unwrap(), "old.tex");
785  }
786
787  #[test]
788  fn readme_xxx_mixed_directives() {
789    let d = tempdir().unwrap();
790    write(d.path(), "intro.tex", "\\documentclass{article}\n");
791    write(d.path(), "paper.tex", "\\documentclass{article}\n");
792    write(d.path(), "junk.tex", "\\documentclass{article}\n");
793    // `paper.tex toplevelfile` short-circuits the heuristic; the
794    // ignore line is unreachable in this test but exercises the
795    // parser's multi-line/multi-kind handling.
796    write(
797      d.path(),
798      "00README.XXX",
799      "junk.tex ignore\npaper.tex toplevelfile\n",
800    );
801    let pick = find_main_tex(d.path()).unwrap();
802    assert_eq!(pick.file_name().unwrap(), "paper.tex");
803  }
804
805  // OXIDIZED_DESIGN #132: a matching `.bbl` sibling outranks the pdf-include
806  // heuristic. Models the witness class (2407.05010 #1721 et al.): the true
807  // main delegates its figures (no direct `\includegraphics`) but ships a
808  // `main.bbl`, while a class how-to/template carries an example
809  // `\includegraphics{fig.png}`. Perl's pdf-before-bbl order (and our old port)
810  // picked the template; bbl-first recovers the real main.
811  #[test]
812  fn bbl_sibling_outranks_pdf_include_marker() {
813    let d = tempdir().unwrap();
814    write(
815      d.path(),
816      "main.tex",
817      "\\documentclass{IEEEtran}\n\\input{sections/intro}\n\\begin{document}\\title{Real}\\end{document}",
818    );
819    write(
820      d.path(),
821      "New_IEEEtran_how-to.tex",
822      "\\documentclass{IEEEtran}\n\\includegraphics[width=1in]{fig1.png}\n\\begin{document}How to.\\end{document}",
823    );
824    write(
825      d.path(),
826      "main.bbl",
827      "\\begin{thebibliography}{1}\\end{thebibliography}\n",
828    );
829    let pick = find_main_tex(d.path()).unwrap();
830    assert_eq!(pick.file_name().unwrap(), "main.tex");
831  }
832
833  // Reorder must not swallow the later common-name tie-break: when >1 candidate
834  // carries a `.bbl` (2506.05564, 2401.07129), the `.bbl` set survives and
835  // main/ms/paper still wins over an alphabetically-earlier sibling.
836  #[test]
837  fn reorder_preserves_common_name_when_multiple_bbl() {
838    let d = tempdir().unwrap();
839    write(
840      d.path(),
841      "aaa.tex",
842      "\\documentclass{article}\n\\includegraphics{x.png}\n",
843    );
844    write(
845      d.path(),
846      "main.tex",
847      "\\documentclass{article}\n\\includegraphics{y.png}\n",
848    );
849    write(d.path(), "aaa.bbl", "");
850    write(d.path(), "main.bbl", "");
851    let pick = find_main_tex(d.path()).unwrap();
852    assert_eq!(pick.file_name().unwrap(), "main.tex");
853  }
854
855  // Parity fix (Perl `heuristic_check_for_pdftex`): the extension must sit
856  // INSIDE the `\includegraphics{…}` argument. The old loose whole-file
857  // `contains` false-positived on templates whose examples are extensionless
858  // (2401.17263 #442 `{icml_numpapers}`) or `.eps` (2403.17719 #859
859  // `{egfigure.eps}`) yet mention a raster extension elsewhere.
860  #[test]
861  fn strict_pdf_marker_rejects_extensionless_eps_and_comments() {
862    let d = tempdir().unwrap();
863    // extensionless arg + an unrelated `.png` mention in prose → not a marker
864    write(
865      d.path(),
866      "example_paper.tex",
867      "\\centerline{\\includegraphics[width=\\columnwidth]{icml_numpapers}}\nResults saved as results.png.\n",
868    );
869    // `.eps` extension is not in the raster/pdf set
870    write(
871      d.path(),
872      "rebuttal.tex",
873      "\\includegraphics[width=0.8\\linewidth]{egfigure.eps}\n",
874    );
875    // commented-out include must not count (behind `%`)
876    write(d.path(), "commented.tex", "%\\includegraphics{fig.png}\n");
877    assert!(!has_pdftex_marker(&d.path().join("example_paper.tex")));
878    assert!(!has_pdftex_marker(&d.path().join("rebuttal.tex")));
879    assert!(!has_pdftex_marker(&d.path().join("commented.tex")));
880  }
881
882  #[test]
883  fn strict_pdf_marker_accepts_arg_extension_and_pdfoutput() {
884    let d = tempdir().unwrap();
885    write(
886      d.path(),
887      "fig.tex",
888      "\\includegraphics[width=2in]{diagram.pdf}\n",
889    );
890    write(d.path(), "gif.tex", "\\includegraphics{anim.gif}\n"); // Perl includes gif
891    write(
892      d.path(),
893      "pdfout.tex",
894      "\\pdfoutput=1\n\\includegraphics{noext}\n",
895    );
896    assert!(has_pdftex_marker(&d.path().join("fig.tex")));
897    assert!(has_pdftex_marker(&d.path().join("gif.tex")));
898    assert!(has_pdftex_marker(&d.path().join("pdfout.tex")));
899  }
900
901  // Multi-top-level detection (find_top_level_texs). Models the real
902  // main+supplement pattern (2408.13687, 2401.07129, …): both documents carry
903  // their own `.bbl`; the supplement self-identifies by title/filename.
904  #[test]
905  fn top_level_texs_appends_supplement() {
906    let d = tempdir().unwrap();
907    write(
908      d.path(),
909      "main.tex",
910      "\\documentclass{article}\n\\title{Quantum error correction}\n",
911    );
912    write(
913      d.path(),
914      "supplement.tex",
915      "\\documentclass{article}\n\\title{Supplementary Information for Quantum error correction}\n",
916    );
917    write(d.path(), "main.bbl", "");
918    write(d.path(), "supplement.bbl", "");
919    let tops = find_top_level_texs(d.path()).unwrap();
920    let names: Vec<_> = tops
921      .iter()
922      .map(|p| p.file_name().unwrap().to_str().unwrap())
923      .collect();
924    assert_eq!(names, vec!["main.tex", "supplement.tex"]);
925    // `find_main_tex` stays the first entry.
926    assert_eq!(find_main_tex(d.path()).unwrap(), tops[0]);
927  }
928
929  // Main first even when the supplement sorts alphabetically before it
930  // (2506.05564: `Note.tex` main + `Appendix.tex` supplement).
931  #[test]
932  fn top_level_texs_orders_main_first_despite_alpha() {
933    let d = tempdir().unwrap();
934    write(
935      d.path(),
936      "Note.tex",
937      "\\documentclass{article}\n\\title{Bayesian Inference of the Landau Parameter}\n",
938    );
939    write(
940      d.path(),
941      "Appendix.tex",
942      "\\documentclass{article}\n\\title{SUPPLEMENTAL MATERIALS}\n",
943    );
944    write(d.path(), "Note.bbl", "");
945    write(d.path(), "Appendix.bbl", "");
946    let tops = find_top_level_texs(d.path()).unwrap();
947    let names: Vec<_> = tops
948      .iter()
949      .map(|p| p.file_name().unwrap().to_str().unwrap())
950      .collect();
951    assert_eq!(names, vec!["Note.tex", "Appendix.tex"]);
952  }
953
954  // Template safety: a bundled template (no `.bbl`, does not self-identify as a
955  // supplement) is never appended, even when it carries example graphics.
956  #[test]
957  fn top_level_texs_excludes_template_without_bbl() {
958    let d = tempdir().unwrap();
959    write(
960      d.path(),
961      "main.tex",
962      "\\documentclass{IEEEtran}\n\\input{sec}\n\\title{Real Paper}\n",
963    );
964    write(
965      d.path(),
966      "New_IEEEtran_how-to.tex",
967      "\\documentclass{IEEEtran}\n\\includegraphics{fig1.png}\n\\title{How to Use the IEEEtran Templates}\n",
968    );
969    write(d.path(), "main.bbl", "");
970    let tops = find_top_level_texs(d.path()).unwrap();
971    let names: Vec<_> = tops
972      .iter()
973      .map(|p| p.file_name().unwrap().to_str().unwrap())
974      .collect();
975    assert_eq!(names, vec!["main.tex"]);
976  }
977
978  // Precision: a supplement-titled document WITHOUT its own `.bbl` is not
979  // auto-detected (conservative residual).
980  #[test]
981  fn supplement_without_bbl_is_not_detected() {
982    let d = tempdir().unwrap();
983    write(
984      d.path(),
985      "main.tex",
986      "\\documentclass{article}\n\\title{Real Paper}\n",
987    );
988    write(
989      d.path(),
990      "supp.tex",
991      "\\documentclass{article}\n\\title{Supplementary Information}\n",
992    );
993    write(d.path(), "main.bbl", "");
994    let tops = find_top_level_texs(d.path()).unwrap();
995    assert_eq!(tops.len(), 1);
996    assert_eq!(tops[0].file_name().unwrap(), "main.tex");
997  }
998
999  // Explicit 00README with two toplevelfile lines → both, main-first even when
1000  // listed supplement-first.
1001  #[test]
1002  fn readme_two_toplevelfiles_ordered_main_first() {
1003    let d = tempdir().unwrap();
1004    write(
1005      d.path(),
1006      "main.tex",
1007      "\\documentclass{article}\n\\title{Paper}\n",
1008    );
1009    write(
1010      d.path(),
1011      "si.tex",
1012      "\\documentclass{article}\n\\title{Supporting Information}\n",
1013    );
1014    write(
1015      d.path(),
1016      "00README.XXX",
1017      "si.tex toplevelfile\nmain.tex toplevelfile\n",
1018    );
1019    let tops = find_top_level_texs(d.path()).unwrap();
1020    let names: Vec<_> = tops
1021      .iter()
1022      .map(|p| p.file_name().unwrap().to_str().unwrap())
1023      .collect();
1024    assert_eq!(names, vec!["main.tex", "si.tex"]);
1025  }
1026}