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  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
337// ---------------------------------------------------------------------------
338// Internal helpers.
339// ---------------------------------------------------------------------------
340
341fn s(msg: &str) -> String { msg.to_string() }
342
343// Perl Pack.pm L25 TEX_EXT = qr/\.(?:[tT](:?[eE][xX]|[xX][tT])|ltx|LTX)$/
344// → .tex, .txt, .ltx (case-insensitive). The `fallback` arm matches Perl
345// Pack/Dir.pm L47: `!/\./ || /\.[^.]{4,}$/` — extension-less or extension
346// ≥4 chars, used when nothing TeX-shaped surfaces in the strict pass.
347fn 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
374// Skip files whose magic bytes identify them as PDF (e.g. arXiv source
375// archives that contain a PDF mis-named with a `.tex` extension).
376pub 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
387/// Parse `00README.json` in `dir` and return the "filename" of the
388/// toplevel source. Perl Pack.pm L68-80: scans `sources[]` for the
389/// entry tagged `usage == "toplevel"`. Minimal hand-rolled JSON
390/// scanner — we don't pull a full JSON dep just for this.
391fn 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
425// ---------------------------------------------------------------------------
426// Pre-compiled regexes used by `find_main_tex`. Parking these as module-
427// level `Lazy<Regex>` keeps a single instance per process and avoids the
428// per-call recompile that nesting them inside the function caused.
429// ---------------------------------------------------------------------------
430
431static 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());
454// Strip a single `%`-comment, stopping at the next `\r` so bare-`\r`
455// line-ended files (Mac classic) preserve post-comment `\documentclass`.
456static 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    // Two candidate documents — main.tex would normally win,
499    // but the 00README directive points at draft.tex.
500    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    // Without a directive, `main.tex` would tie with `old.tex`
511    // and the "common name" heuristic prefers `main.tex`. With
512    // an `ignore` directive on main.tex, the heuristic falls back
513    // to `old.tex`.
514    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    // `paper.tex toplevelfile` short-circuits the heuristic; the
528    // ignore line is unreachable in this test but exercises the
529    // parser's multi-line/multi-kind handling.
530    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}