Skip to main content

latexml_core/util/
pathname.rs

1#[cfg(feature = "kpathsea")]
2use std::sync::Mutex;
3use std::{
4  env,
5  path::{Path, PathBuf},
6  sync::OnceLock,
7};
8
9#[cfg(feature = "kpathsea")]
10use kpathsea::Kpaths;
11use once_cell::sync::Lazy;
12use regex::Regex;
13
14/// configuration for filesystem search.
15/// Mirrors Perl `LaTeXML::Util::Pathname::pathname_find`'s named-arg options;
16/// kpsewhich-fallback is NOT one of them — that lives in higher-level
17/// `LaTeXML::Package::FindFile_aux`, which calls `pathname_kpsewhich` after
18/// `pathname_find` returns empty. Keep this struct directory-search-only
19/// for parity.
20#[derive(Debug, Clone, Default)]
21pub struct PathnameFindOptions {
22  /// the allowed/requested paths to search in
23  pub paths:               Option<Vec<String>>,
24  /// the file extensions to search for
25  pub extensions:          Option<Vec<String>>,
26  /// the location of the installation subdirectory (deprecated?)
27  pub installation_subdir: Option<String>,
28}
29
30static LITERAL_PROTOCOL: &str = "literal:";
31static HOME_TILDE: &str = "~";
32// `HOME` first (Unix, and set by some Windows shells), then Windows'
33// native `USERPROFILE`, so `~` expansion works on both platforms.
34static HOME_PATH: Lazy<String> =
35  Lazy::new(
36    || match env::var_os("HOME").or_else(|| env::var_os("USERPROFILE")) {
37      Some(val) => val.to_string_lossy().into_owned(),
38      _ => s!("~"),
39    },
40  );
41static PROTOCOL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(https|http|ftp):").unwrap());
42// Match Perl LaTeXML's permissive filename behavior: filenames may
43// contain commas, parens, ampersands, etc. that some user paths legitimately
44// use (e.g. `\input{5-Ack,terms}` resolving to `5-Ack,terms.tex`). Only
45// flag genuinely dangerous patterns: shell metacharacters that would
46// enable command injection via kpathsea or `\openin`. Driver: 2308.13679
47// `\input{5-Ack,terms}`. Mirrors Perl's missing nasty-check (Perl simply
48// passes filenames to kpathsea without a pre-filter).
49static PATHNAME_IS_NASTY_RE: Lazy<Regex> =
50  Lazy::new(|| Regex::new(r#"[`$;|<>"\x00\n\r]"#).unwrap());
51// TODO: This is very pragmatic for now, we ought to use a real URL path library long-term
52static URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\w+://(.+)/([^/]+)$").unwrap());
53// Perl `pathname_is_url` is `$pathname =~ /^($PROTOCOL_RE)/` — the protocol is
54// ANCHORED at the start. The previous `is_url` used `URL_RE` (`^\w+://…`),
55// whose leading `\w+` (which includes `_`) matches a filename PREFIX like
56// `myers_http`, so a JabRef `\bibAnnoteFile{myers_http://…/welcome.html_2014}`
57// key (a filename, NOT a URL) read as a URL → `find_file` returned "exists" →
58// `\IfFileExists` took its true branch → the `_` in the key got typeset in
59// text mode ("Script _ can only appear in math mode"; witness 1509.01434).
60// Match Perl: only `http`/`https`/`ftp` followed by `:` at the very start.
61static URL_PREFIX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?:https|http|ftp):").unwrap());
62
63/// Process-global kpathsea handle (cross-thread shared, NOT
64/// `thread_local!`).
65///
66/// **Why an exception to the project's no-`Mutex` rule:**
67/// `kpathsea-rs` wraps a C library that maintains process-wide global
68/// state. Calling `Kpaths::new()` more than once per process (as the
69/// per-thread `thread_local!` pattern would do) re-runs the C-side
70/// init and can corrupt internal tables / leak file descriptors;
71/// concretely on systems without TeXLive installed, the second init
72/// fails with "Can't get directory of program name" and previously
73/// crashed `06_cluster_regressions`. The Mutex here is necessary to
74/// guarantee single-init AND single-active-call semantics for the
75/// underlying non-thread-safe C API. Same class of carve-out as
76/// `latexml_core::watchdog::PRE_EXIT_HOOK`. See
77/// `feedback_no_mutex_use_thread_local` in user memory for the
78/// general rule.
79#[cfg(feature = "kpathsea")]
80static KPSE: Lazy<Mutex<Option<Kpaths>>> = Lazy::new(|| Mutex::new(select_kpaths()));
81
82/// Which kpathsea backend this process resolved. Reported by
83/// [`kpathsea_backend`] so a host-resolution problem is diagnosable from the
84/// log instead of surfacing only as indistinguishable "can't find file" errors.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum KpathseaBackend {
87  /// Linked libkpathsea, resolved in-process (the fast path).
88  InProcess,
89  /// Delegating to the host's `kpsewhich` executable.
90  Subprocess,
91  /// No backend could be constructed — EVERY file lookup returns `None`.
92  Unavailable,
93}
94
95impl KpathseaBackend {
96  /// Short label, for the per-conversion log line.
97  pub fn as_str(self) -> &'static str {
98    match self {
99      Self::InProcess => "in-process",
100      Self::Subprocess => "subprocess kpsewhich",
101      Self::Unavailable => "unavailable",
102    }
103  }
104}
105
106/// The resolved backend and why, recorded by `select_kpaths`.
107#[cfg(feature = "kpathsea")]
108static BACKEND: OnceLock<(KpathseaBackend, &'static str)> = OnceLock::new();
109
110/// The backend this process resolved, with a short reason. Forces the `KPSE`
111/// initialization so the answer is always the real one.
112#[cfg(feature = "kpathsea")]
113pub fn kpathsea_backend() -> (KpathseaBackend, &'static str) {
114  drop(KPSE.lock().unwrap());
115  *BACKEND
116    .get()
117    .unwrap_or(&(KpathseaBackend::Unavailable, "not initialized"))
118}
119
120/// Without the `kpathsea` feature there is no backend at all.
121#[cfg(not(feature = "kpathsea"))]
122pub fn kpathsea_backend() -> (KpathseaBackend, &'static str) {
123  (
124    KpathseaBackend::Unavailable,
125    "built without the kpathsea feature",
126  )
127}
128
129/// The backend-selection policy, with its effects injected so every branch is
130/// testable without a MiKTeX (or TeX-less) host.
131///
132/// The in-process (statically linked) backend is the fast path — it resolves
133/// against the host `ls-R` with no subprocess per lookup, ~0.5 s/conversion
134/// cheaper than shelling out — so it is preferred wherever it actually works,
135/// with the subprocess backend as the portable fallback. Net: TeX Live keeps
136/// the in-process speed, MiKTeX works with no configuration, one binary.
137#[cfg(feature = "kpathsea")]
138fn choose_kpaths(
139  banner: Option<&str>,
140  new_in_process: impl FnOnce() -> Result<Kpaths, &'static str>,
141  new_subprocess: impl Fn() -> Result<Kpaths, &'static str>,
142  can_resolve: impl FnOnce(&Kpaths) -> bool,
143) -> (Option<Kpaths>, KpathseaBackend, &'static str) {
144  // MiKTeX stores its file database in an MPM `fndb` that a statically-linked
145  // libkpathsea cannot read (MiKTeX ships no `ls-R`), so the in-process backend
146  // resolves nothing there. Detecting it from the `kpsewhich --version` banner
147  // BEFORE any `Kpaths::new()` also avoids the C library's "configuration file
148  // texmf.cnf not found" warning while it fails to anchor on the MiKTeX tree.
149  if banner.is_some_and(|b| b.contains("MiKTeX"))
150    && let Ok(subprocess) = new_subprocess()
151  {
152    return (Some(subprocess), KpathseaBackend::Subprocess, "MiKTeX host");
153  }
154  match new_in_process() {
155    // Sentinel backstop for any OTHER distro whose tree the linked lib can't
156    // read. `new_subprocess()` is pure Rust (no C-side re-init), so this second
157    // construction is safe next to the Mutex carve-out documented above.
158    Ok(kpse) if kpse.is_in_process() && !can_resolve(&kpse) => match new_subprocess() {
159      Ok(subprocess) => (
160        Some(subprocess),
161        KpathseaBackend::Subprocess,
162        "linked libkpathsea resolved no host files",
163      ),
164      // Keep the in-process handle: degraded beats nothing.
165      Err(_) => (
166        Some(kpse),
167        KpathseaBackend::InProcess,
168        "linked libkpathsea resolves no host files, and no kpsewhich to fall back to",
169      ),
170    },
171    Ok(kpse) if kpse.is_in_process() => (Some(kpse), KpathseaBackend::InProcess, "linked"),
172    Ok(kpse) => (
173      Some(kpse),
174      KpathseaBackend::Subprocess,
175      "no linked libkpathsea",
176    ),
177    // Never give up without trying the other backend: this is the only path
178    // that would otherwise disable file resolution entirely, and it used to do
179    // so silently (`Kpaths::new().ok()?`).
180    Err(_) => match new_subprocess() {
181      Ok(subprocess) => (
182        Some(subprocess),
183        KpathseaBackend::Subprocess,
184        "libkpathsea unavailable",
185      ),
186      Err(_) => (
187        None,
188        KpathseaBackend::Unavailable,
189        "no usable libkpathsea and no kpsewhich executable",
190      ),
191    },
192  }
193}
194
195/// Pick the kpathsea backend that can actually resolve host files, and record
196/// the choice for [`kpathsea_backend`].
197#[cfg(feature = "kpathsea")]
198fn select_kpaths() -> Option<Kpaths> {
199  let (kpse, backend, why) = choose_kpaths(
200    ambient_kpsewhich_version(),
201    Kpaths::new,
202    Kpaths::new_subprocess,
203    // `cmr10.tfm` is present in every TeX distribution, and the probe returns
204    // `None` fast on MiKTeX — no directory walk.
205    |kpse| kpse.find_file("cmr10.tfm").is_some(),
206  );
207  let _ = BACKEND.set((backend, why));
208  kpse
209}
210
211/// The ambient `kpsewhich --version` banner (full stdout), memoized for the
212/// process. It is a global property of the host TeX install, and several
213/// consumers read it — kpathsea backend selection (`select_kpaths`) and
214/// ambient-year detection (in `latexml_engine`; the year-based latex-dump
215/// staleness check in turn consumes that detected year) — so `kpsewhich` is
216/// spawned at most once. Returns `None` if kpsewhich is absent or the call
217/// fails.
218///
219/// NOT gated on the `kpathsea` feature: it is a plain subprocess probe (no C
220/// library), so the year/stamp logic can share it even in the host-side codegen
221/// build. Lives here — the lowest crate that constructs `Kpaths` — so both the
222/// backend choice and `latexml_engine` resolve the same single spawn.
223pub fn ambient_kpsewhich_version() -> Option<&'static str> {
224  static BANNER: OnceLock<Option<String>> = OnceLock::new();
225  BANNER
226    .get_or_init(|| {
227      let out = std::process::Command::new("kpsewhich")
228        .arg("--version")
229        .output()
230        .ok()?;
231      if !out.status.success() {
232        return None;
233      }
234      String::from_utf8(out.stdout).ok()
235    })
236    .as_deref()
237}
238
239/// Force-initialize the kpathsea global state and warm up the per-
240/// format suffix tables.
241///
242/// **Why:** `Kpaths::find_file` lazily inits the kpse format-info
243/// table for each format type the first time a matching filename is
244/// looked up. The chain is
245/// `find_file → guess_format_from_filename → kpathsea_init_format →
246/// kpathsea_init_db → kpathsea_cnf_get → hash_insert_normalized`,
247/// taking ~30-40 ms total across the first dozen lookups. Profile
248/// data on 1910.01256 attributes ~3.5% of wall to that chain.
249///
250/// **What this does:** acquires the `KPSE` mutex once and runs a
251/// single `find_file` probe per common file format. Each probe
252/// guarantees `kpathsea_init_format` runs for that format type, so
253/// every subsequent real lookup hits the post-init fast path.
254///
255/// **Concurrency:** safe to invoke on a background thread spawned at
256/// process start. `KPSE` is process-global (`Lazy<Mutex<…>>`), so the
257/// init done on a background thread is visible to the main thread.
258/// The Mutex briefly serializes the prewarm against the main thread's
259/// first real lookup, but dump load + arg parsing take >50 ms before
260/// digest reaches its first package resolution, by which point the
261/// prewarm is usually finished. Idempotent: re-entry while in flight
262/// is a no-op (lock contention only).
263#[cfg(feature = "kpathsea")]
264pub fn prewarm_kpathsea() {
265  // Run exactly once per process: libkpathsea's format-info tables are
266  // process-global, so warming them once is enough. The `Once` also lets the
267  // CLI's early background prewarm and the shared `Converter::initialize_session`
268  // call coordinate — whichever runs first warms; any other caller blocks here
269  // until it completes (guaranteeing warm-before-first-lookup) instead of
270  // re-taking the `KPSE` mutex and re-probing 11 sentinels on every session.
271  report_unavailable_kpathsea();
272  static PREWARM_ONCE: std::sync::Once = std::sync::Once::new();
273  PREWARM_ONCE.call_once(|| {
274    let kpse_guard = KPSE.lock().unwrap();
275    let Some(ref kpse) = *kpse_guard else {
276      return;
277    };
278    // Subprocess backend (kpathsea 0.3, hosts without libkpathsea — e.g.
279    // MacTeX): there is no in-process format-info table to warm, and each
280    // sentinel would cost a real `kpsewhich` subprocess invocation
281    // (~10-20 ms × 11). The backend builds its own ls-R cache lazily on
282    // the first real lookup instead.
283    if !kpse.is_in_process() {
284      return;
285    }
286    for sentinel in &[
287      "warmup_lxoxide.tex",
288      "warmup_lxoxide.sty",
289      "warmup_lxoxide.cls",
290      "warmup_lxoxide.def",
291      "warmup_lxoxide.bst",
292      "warmup_lxoxide.fmt",
293      "warmup_lxoxide.afm",
294      "warmup_lxoxide.tfm",
295      "warmup_lxoxide.pfb",
296      "warmup_lxoxide.enc",
297      "warmup_lxoxide.map",
298    ] {
299      // catch_unwind defends against kpathsea-0.2.3 overflow bug (see
300      // kpsewhich docs).
301      let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(sentinel)));
302    }
303  });
304}
305
306/// No-op when built without the `kpathsea` feature (e.g. the host-side
307/// proc-macro build, which never resolves TeX files).
308#[cfg(not(feature = "kpathsea"))]
309pub fn prewarm_kpathsea() {}
310
311// Perl: $pathname =~ s|^($PROTOCOL_RE//[^/]*)/|/|
312static CANONICAL_URL_RE: Lazy<Regex> =
313  Lazy::new(|| Regex::new(r"^((?:https|http|ftp)://[^/]*)").unwrap());
314
315// static ref INSTALLDIRS : Vec<String> = match env::current_exe() {
316//     Ok(exe_path) => {
317//       match exe_path.as_path().parent() {
318//         Some(_) => Vec::new(),
319//         // Some(p) => vec![
320//         //                 p.to_string_lossy().to_string() + ".",
321//         //                 p.to_string_lossy().to_string() + "./..",
322//         //                 p.to_string_lossy().to_string() + "./../..",
323//         //                 p.to_string_lossy().to_string() + "./../../..",
324//         //                 p.to_string_lossy().to_string() + "./../../../.."],
325
326// TODO: HACK, see note on INSTALLDIRS further down
327//         None => Vec::new()
328//       }
329//     },
330//     _ => Vec::new()
331//   };
332
333// TODO:
334// grep { (-f "$_.pm") && (-d $_) }
335// map { pathname_canonical($_ . $SEP . 'LaTeXML') } @INC;    # [CONSTANT]
336
337/// checks if the path is a conforming URL string
338pub fn is_url(path: &str) -> bool { URL_PREFIX_RE.is_match(path) }
339/// checks if the path starts with the "literal:" protocol
340pub fn is_literaldata(data: &str) -> bool { data.starts_with(LITERAL_PROTOCOL) }
341
342/// check whether a pathname is reloadable as a TeX definition
343pub fn is_reloadable(pathname: &str) -> bool {
344  let (_dir, _name, ext) = split(pathname);
345  // babel.sty exception:
346  // we know the same .ldf file may be reloaded with a different option,
347  // to load an adjacently defined language, so allow that.
348  ext == "ldf"
349}
350/// Check whether a pathname is a raw TeX source or definition file.
351/// Perl: pathname_is_raw
352pub fn is_raw(pathname: &str) -> bool {
353  matches!(
354    extension(pathname).as_str(),
355    "tex" | "pool" | "sty" | "cls" | "clo" | "cnf" | "cfg" | "ldf" | "def" | "dfu"
356  )
357}
358
359/// absolute paths start with the filesystem root - check if this is one
360pub fn is_absolute(path: &str) -> bool { Path::new(&canonical(path)).is_absolute() }
361/// convert a (possibly relative) file path to an absolute one
362///
363/// `std::fs::canonicalize` requires the path to exist; many callers hand
364/// us paths that haven't been resolved yet (e.g. `\import{subdir}{f.sty}`
365/// constructs `subdir/f.sty` before `find_file` probes other dirs).
366/// Mirror Perl's `Cwd::abs_path`-style behavior: produce a lexically
367/// absolute path joined against `current_dir()` when the input is
368/// relative, then run it through our `canonical()` to collapse `.`/`..`
369/// components.
370///
371/// Panics only if `current_dir()` itself fails — that means the cwd was
372/// deleted out from under us, which we cannot safely resolve a relative
373/// path against (and silently returning the input could let a relative
374/// file reference target an attacker-controlled path).
375pub fn absolute(path: &str) -> String {
376  let p = Path::new(path);
377  let joined: PathBuf = if p.is_absolute() {
378    p.to_path_buf()
379  } else {
380    let cwd = env::current_dir().expect("cannot make path absolute: current_dir() failed");
381    cwd.join(p)
382  };
383  canonical(&joined.to_string_lossy())
384}
385
386/// Split the pathname into components (dir,name,type).
387/// If pathname is absolute, dir starts with volume or '/'
388pub fn split(pathname: &str) -> (String, String, String) {
389  let canonical_pathname = canonical(pathname);
390  let canonical_path = Path::new(&canonical_pathname);
391  let pathdir = match canonical_path.parent() {
392    Some(dir) => dir.to_string_lossy().to_string(),
393    None => String::new(),
394  };
395  let name = match canonical_path.file_stem() {
396    Some(n) => n.to_string_lossy().to_string(),
397    None => String::new(),
398  };
399  // Perl pathname_split preserves case: `$name =~ s/\.([^\.]+)$//`
400  let pathname_ext = match canonical_path.extension() {
401    Some(e) => e.to_string_lossy().to_string(),
402    None => String::new(),
403  };
404  (pathdir, name, pathname_ext)
405}
406
407///  Simple logic for splitting a URL into protocol://base/path
408pub fn url_split(url: &str) -> (&str, &str) {
409  if let Some(caps) = URL_RE.captures(url) {
410    (
411      caps.get(1).map_or("", |m| m.as_str()),
412      caps.get(2).map_or("", |m| m.as_str()),
413    )
414  } else {
415    (url, "index.tex") // Well, what other default makes sense?
416  }
417}
418
419/// Canonicalize a pathname by simplifying redundant separators, `.` and `..` components.
420/// Matches Perl's pathname_canonical from Pathname.pm.
421pub fn canonical(pathname: &str) -> String {
422  if is_literaldata(pathname) {
423    return pathname.to_owned();
424  }
425  // Don't call is_absolute, etc, here, cause THEY call US!
426  let home_path: &str = &HOME_PATH;
427
428  let mut pathname = if pathname.starts_with(HOME_TILDE) {
429    pathname.replacen(HOME_TILDE, home_path, 1)
430  } else {
431    pathname.to_string()
432  };
433
434  // Windows: the whole string-pathname layer speaks `/`, faithful to Perl
435  // (whose Windows pathname_canonical likewise sees mostly `/`; kpsewhich
436  // on TL-Windows returns `C:/texlive/...`). OS-originated strings
437  // (std::env::temp_dir, current_dir, PathBuf displays) arrive
438  // `\`-separated, so normalize here — the single choke point every
439  // pathname_* helper funnels through. Unix is untouched: `\` is a legal
440  // filename byte there.
441  #[cfg(windows)]
442  {
443    if pathname.contains('\\') {
444      pathname = pathname.replace('\\', "/");
445    }
446  }
447
448  // Handle URL prefix: strip protocol://host before normalizing path
449  let url_prefix = if let Some(caps) = CANONICAL_URL_RE.captures(&pathname) {
450    let prefix = caps.get(1).unwrap().as_str().to_string();
451    pathname = pathname[prefix.len()..].to_string();
452    Some(prefix)
453  } else {
454    None
455  };
456
457  // Perl: $pathname =~ s|/\./|/|g;
458  while pathname.contains("/./") {
459    pathname = pathname.replace("/./", "/");
460  }
461  // Perl: while ($pathname =~ s|/(?!\.\./)[^/]+/\.\.(/|$)|$1|) { }
462  // Collapse /foo/.. patterns but not /../..
463  // Implemented without lookahead since the regex crate doesn't support it.
464  loop {
465    let mut changed = false;
466    // Find /component/.. where component is not ".."
467    if let Some(dotdot_pos) = pathname.find("/..") {
468      // Check this is actually /../ or /..$ (end of string)
469      let after = dotdot_pos + 3;
470      if after == pathname.len() || pathname.as_bytes().get(after) == Some(&b'/') {
471        // Find the preceding component: look backwards from dotdot_pos for '/'
472        if dotdot_pos > 0 {
473          let prefix = &pathname[..dotdot_pos];
474          if let Some(slash_pos) = prefix.rfind('/') {
475            let component = &pathname[slash_pos + 1..dotdot_pos];
476            if component != ".." && !component.is_empty() {
477              // Replace /component/.. with the trailing part
478              let trail = &pathname[after..];
479              pathname = format!("{}{}", &pathname[..slash_pos], trail);
480              changed = true;
481            }
482          } else if prefix != ".." {
483            // No leading slash, e.g. "foo/.."
484            let trail = &pathname[after..];
485            pathname = if let Some(stripped) = trail.strip_prefix('/') {
486              stripped.to_string()
487            } else {
488              trail.to_string()
489            };
490            if pathname.is_empty() {
491              pathname = ".".to_string();
492            }
493            changed = true;
494          }
495        }
496      }
497    }
498    if !changed {
499      break;
500    }
501  }
502  // Perl: $pathname =~ s|^\./(.)|$1|; — reduce ./foo to foo, but preserve ./
503  if pathname.starts_with("./") && pathname.len() > 2 {
504    pathname = pathname[2..].to_string();
505  }
506  match url_prefix {
507    Some(prefix) => format!("{}{}", prefix, pathname),
508    None => pathname,
509  }
510}
511
512/// Note that this returns ONLY recognized protocols!
513pub fn protocol(pathname: &str) -> String {
514  if let Some(cap) = PROTOCOL_RE.captures(pathname) {
515    cap.get(1).map_or(String::new(), |m| m.as_str().to_string())
516  } else if is_literaldata(pathname) {
517    "literal".to_string()
518  } else {
519    "file".to_string()
520  }
521}
522
523/// combine a directory and a base name into a full path
524pub fn concat(dir: &str, file: &str) -> String {
525  if dir.is_empty() {
526    file.to_owned()
527  } else if file.is_empty() || file == "." {
528    dir.to_owned()
529  } else {
530    // Join with a literal '/', as Perl's pathname_concat does — the
531    // string-pathname layer is '/'-separated on every platform. (The
532    // previous PathBuf::push produced '\' on Windows, which the
533    // Perl-faithful string logic in `canonical` cannot normalize.)
534    canonical(&format!("{dir}/{file}"))
535  }
536}
537
538/// Expand a directory for the kpsewhich `//` recursive-search convention: the
539/// directory itself, followed by every subdirectory beneath it. Traversal is
540/// breadth-first and each level is sorted, so shallower directories take search
541/// precedence and the result is deterministic. Hidden (dot-prefixed)
542/// subdirectories are skipped, as kpsewhich does. Returns just `[base]` if the
543/// tree cannot be read.
544fn expand_recursive_dirs(base: &str) -> Vec<String> {
545  let mut out = vec![base.to_string()];
546  // Cycle guard: a symlinked directory can point back at an ancestor. Dedupe on
547  // the CANONICAL path so a symlink loop can't make the walk run forever.
548  let mut visited = std::collections::HashSet::new();
549  if let Ok(canon) = std::fs::canonicalize(base) {
550    visited.insert(canon);
551  }
552  let mut queue = std::collections::VecDeque::from([PathBuf::from(base)]);
553  while let Some(dir) = queue.pop_front() {
554    let Ok(entries) = std::fs::read_dir(&dir) else {
555      continue;
556    };
557    let mut children: Vec<PathBuf> = entries
558      .flatten()
559      .map(|e| e.path())
560      .filter(|p| p.is_dir())
561      .filter(|p| {
562        // Skip hidden dot-directories (.git, .svn, …), matching kpsewhich.
563        p.file_name()
564          .and_then(|n| n.to_str())
565          .is_none_or(|n| !n.starts_with('.'))
566      })
567      .collect();
568    children.sort();
569    for child in children {
570      // Skip any directory (reached directly or via symlink) already walked.
571      if let Ok(canon) = std::fs::canonicalize(&child)
572        && !visited.insert(canon)
573      {
574        continue;
575      }
576      if let Some(s) = child.to_str() {
577        // Keep the `/`-separator convention the pathname layer speaks.
578        out.push(s.replace('\\', "/"));
579        queue.push_back(child);
580      }
581    }
582  }
583  out
584}
585
586/// It's presumably cheep to concatinate all the pathnames,
587/// relative to the cost of testing for files,
588/// and this simplifies overall.
589pub fn candidate_pathnames(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
590  let mut dirs: Vec<String> = Vec::new();
591  let canonical_pathname = if pathname != "*" {
592    canonical(pathname)
593  } else {
594    pathname.to_owned()
595  };
596
597  let (pathdir, name_stem, pathname_ext) = split(&canonical_pathname);
598  // Perl: $name .= '.' . $type if (defined $type) && ($type ne '');
599  // Re-attach the extension to the name, as Perl does after split
600  let name = if !pathname_ext.is_empty() {
601    format!("{}.{}", name_stem, pathname_ext)
602  } else {
603    name_stem
604  };
605
606  let cwd = cwd();
607
608  // generate the set of search paths we'll use.
609  if is_absolute(&canonical_pathname) {
610    dirs.push(pathdir.clone());
611  } else if let Some(paths) = options.paths {
612    for p in paths {
613      // kpsewhich convention: a search path ending in `//` is searched
614      // RECURSIVELY (the directory and its whole subtree). Split the marker off
615      // first, then resolve the base to an absolute directory.
616      let (base, recursive) = match p.strip_suffix("//") {
617        Some(b) => (b.trim_end_matches('/'), true),
618        None => (p.as_str(), false),
619      };
620      // Complete the search paths by prepending current dir to relative paths,
621      let pp_base = if is_absolute(base) {
622        canonical(base)
623      } else {
624        concat(&cwd, base)
625      };
626      // Expand the recursive marker to `[base, ...every subdirectory]`; a plain
627      // path stays a single directory.
628      let roots = if recursive {
629        expand_recursive_dirs(&pp_base)
630      } else {
631        vec![pp_base]
632      };
633      for root in roots {
634        let pp = concat(&root, &pathdir);
635        // but only include each dir ONCE
636        if !dirs.contains(&pp) {
637          dirs.push(pp);
638        }
639      }
640    }
641  }
642  // Perl: push(@dirs, pathname_concat($cwd, $pathdir)) unless @dirs;
643  // Only add cwd if no search paths were given (fallback)
644  if dirs.is_empty() {
645    let from_cwd = concat(&cwd, &pathdir);
646    if !dirs.contains(&from_cwd) {
647      dirs.push(from_cwd);
648    }
649  }
650
651  // TODO: The use of INSTALLDIRS should be rethought entirely, as Rust currently doesn't have a
652  // native concept of "installing" a crate and its resources there either needs to be a
653  // more sophisticated build process, OR, a compile step that translates all model/binding
654  // dependencies into rust code. which would allow bundling them side-by-side with the
655  // main application.
656
657  // And, if installation dir specified, append it.
658  if let Some(subdir) = options.installation_subdir {
659    // dirs.extend((*INSTALLDIRS).iter().map(|dir| concat(dir, &subdir)));
660    let full_subdir = concat(&cwd, &subdir);
661    if Path::new(&full_subdir).exists() {
662      dirs.push(full_subdir);
663    } else {
664      let full_subdir_oneup = concat(&s!("{}/..", cwd), &subdir);
665      if Path::new(&full_subdir_oneup).exists() {
666        dirs.push(full_subdir_oneup);
667      }
668    }
669  }
670  // extract the desired extensions.
671  // Perl: the extensions from `types` option are applied to the already-reassembled name.
672  // Since name already has its extension, matching an existing extension pushes '' (exact match).
673  let mut exts = Vec::new();
674  if let Some(ext_vec) = options.extensions {
675    for ext in ext_vec {
676      if ext.is_empty() {
677        exts.push(String::new());
678      } else if ext == "*" {
679        exts.push(s!(".*"));
680        exts.push(String::new());
681      } else if !pathname_ext.is_empty() && pathname_ext.eq_ignore_ascii_case(&ext) {
682        // Perl Pathname.pm L353: `if ($pathname =~ /\.\Q$ext\E$/i)` — /i
683        // makes this a case-insensitive extension match; either case of
684        // file extension matches either case of requested type.
685        exts.push(String::new());
686        // Also push the extension itself (Perl pushes both)
687        exts.push(format!(".{}", ext));
688      } else {
689        exts.push(format!(".{}", ext));
690      }
691    }
692  }
693  if exts.is_empty() {
694    exts.push(String::new());
695  }
696
697  let mut paths = Vec::new();
698  // Now, combine; precedence to leading directories.
699  for dir in &dirs {
700    for ext in &exts {
701      if name == "*" {
702        // TODO: wildcard directory listing support
703      } else {
704        paths.push(concat(dir, &(name.clone() + ext)));
705      }
706    }
707  }
708  paths
709}
710
711/// find the requested `pathname` using the `options` search configuration.
712/// Mirrors Perl `pathname_find` (LaTeXML/Util/Pathname.pm L376-392): directory
713/// search with strict-case match preferred, falling back to a
714/// case-insensitive directory scan. The fallback is required for arxiv
715/// papers shipping uppercase filenames (e.g. `PASJ95.STY` referenced as
716/// `PASJ95.sty`) — Perl's regex pair pushes both strict and `/i` matches
717/// and returns the strict ones if any exist, otherwise the case-insensitive
718/// matches. kpsewhich is the caller's responsibility (see
719/// `LaTeXML::Package::FindFile_aux`).
720pub fn find(pathname: &str, options: PathnameFindOptions) -> Option<String> {
721  if pathname.is_empty() {
722    return None;
723  }
724  let paths = candidate_pathnames(pathname, options);
725  // Pass 1: strict-case existence check (the fast path; matches Perl's
726  // `$local_file =~ m/$regex/` strict regex).
727  for path in &paths {
728    if Path::new(path).exists() {
729      return Some(path.clone());
730    }
731  }
732  // Pass 2: case-insensitive directory scan (Perl's `/i` regex fallback).
733  // Only fired when no strict match existed; mirrors Perl's
734  // `return @paths ? @paths : @nocase_paths` ordering.
735  for path in &paths {
736    let p = Path::new(path);
737    let dir = match p.parent() {
738      Some(d) if !d.as_os_str().is_empty() => d,
739      _ => Path::new("."),
740    };
741    let target = match p.file_name().and_then(|n| n.to_str()) {
742      Some(n) => n,
743      None => continue,
744    };
745    let entries = match std::fs::read_dir(dir) {
746      Ok(e) => e,
747      Err(_) => continue,
748    };
749    for entry in entries.flatten() {
750      if let Some(name) = entry.file_name().to_str()
751        && name.eq_ignore_ascii_case(target)
752      {
753        return entry.path().to_str().map(String::from);
754      }
755    }
756  }
757  None
758}
759
760/// transform to a canonical file name, via `Path::file_name`
761pub fn file_name(pathname: &str) -> String {
762  let canonical_pathname = canonical(pathname);
763  let canonical_path = Path::new(&canonical_pathname);
764  match canonical_path.file_name() {
765    Some(e) => e.to_string_lossy().to_string(),
766    None => String::new(),
767  }
768}
769
770/// transform to a base name (via `Path::file_stem`)
771/// Note: Perl's pathname_name returns the stem without extension and without case change.
772pub fn file_stem(pathname: &str) -> String {
773  let canonical_pathname = canonical(pathname);
774  let canonical_path = Path::new(&canonical_pathname);
775  match canonical_path.file_stem() {
776    Some(e) => e.to_string_lossy().to_string(),
777    None => String::new(),
778  }
779}
780
781/// obtain the directory portion of a pathname (via `Path::parent`)
782/// Matches Perl's pathname_directory.
783pub fn directory(pathname: &str) -> String {
784  let canonical_pathname = canonical(pathname);
785  let canonical_path = Path::new(&canonical_pathname);
786  match canonical_path.parent() {
787    Some(e) => e.to_string_lossy().to_string(),
788    None => String::new(),
789  }
790}
791
792/// obtain the extension portion of a pathname (via `Path::extension`).
793/// Perl's `pathname_type` preserves case; callers that need a lowercased
794/// form should apply `.to_ascii_lowercase()` themselves.
795pub fn extension(pathname: &str) -> String {
796  let canonical_pathname = canonical(pathname);
797  let canonical_path = Path::new(&canonical_pathname);
798  match canonical_path.extension() {
799    Some(e) => e.to_string_lossy().to_string(),
800    None => String::new(),
801  }
802}
803
804/// Compose a pathname from dir, name, type components.
805/// Port of Perl's pathname_make(%pieces).
806pub fn make(dir: Option<&str>, name: Option<&str>, ext: Option<&str>) -> String {
807  let mut result = String::new();
808  if let Some(d) = dir {
809    result.push_str(d);
810  }
811  if let Some(n) = name {
812    if !result.is_empty() && !result.ends_with('/') {
813      result.push('/');
814    }
815    result.push_str(n);
816  }
817  if let Some(t) = ext
818    && !t.is_empty()
819  {
820    result.push('.');
821    result.push_str(t);
822  }
823  canonical(&result)
824}
825
826/// Lexical relative path from `base` to `target`, with `..` for the divergent
827/// tail of `base` — the semantics of Perl's `File::Spec->abs2rel`, which
828/// `pathname_relative` is built on. Component-based, no symlink resolution.
829/// Both sides must be absolute (the only case `relative` calls it with); if
830/// they share no root, falls back to the target string.
831fn abs2rel(target: &Path, base: &Path) -> String {
832  use std::path::Component;
833  if !target.is_absolute() || !base.is_absolute() {
834    return target.to_string_lossy().to_string();
835  }
836  let t: Vec<Component> = target.components().collect();
837  let b: Vec<Component> = base.components().collect();
838  let common = t.iter().zip(b.iter()).take_while(|(a, c)| a == c).count();
839  if common == 0 {
840    return target.to_string_lossy().to_string();
841  }
842  let mut result = PathBuf::new();
843  for _ in 0..(b.len() - common) {
844    result.push("..");
845  }
846  for comp in &t[common..] {
847    result.push(comp.as_os_str());
848  }
849  // `PathBuf` re-joins with the OS separator, so on Windows the result is
850  // `\`-separated — but this feeds forward-slash-only outputs (graphic
851  // `candidates`, resource URLs) that must match Perl/pdflatex on every
852  // platform. Normalize on Windows only; on Unix `\` is a legal filename byte
853  // (and the parts are already `/`-joined), so the rewrite is compiled out
854  // rather than risk corrupting a backslash-bearing name.
855  #[cfg(windows)]
856  {
857    result.to_string_lossy().replace('\\', "/")
858  }
859  #[cfg(not(windows))]
860  {
861    result.to_string_lossy().into_owned()
862  }
863}
864
865/// Make a pathname relative to a base directory.
866/// Port of Perl's pathname_relative($pathname, $base).
867pub fn relative(pathname: &str, base: &str) -> String {
868  let canonical_pathname = canonical(pathname);
869  if base.is_empty() || !is_absolute(&canonical_pathname) {
870    return canonical_pathname;
871  }
872  let canonical_base = canonical(base);
873  // Perl's pathname_relative uses File::Spec->abs2rel, which emits a `../…`
874  // path when `pathname` is NOT a descendant of `base` (a sibling tree, e.g.
875  // a graphic reached through `\subimport*{../A/child/}` — issue #698).
876  // `strip_prefix` can only strip a descendant prefix, so it used to fall back
877  // to the raw ABSOLUTE path there, which then leaked into resource/graphic
878  // URLs. abs2rel matches Perl and never leaks an absolute path.
879  abs2rel(Path::new(&canonical_pathname), Path::new(&canonical_base))
880}
881
882/// Find all matching files (like pathname_findall).
883/// Port of Perl's pathname_findall($pathname, %options).
884pub fn findall(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
885  candidate_pathnames(pathname, options)
886}
887
888// Memo store for `kpsewhich` (see its doc below).
889#[cfg(feature = "kpathsea")]
890std::thread_local! {
891  static KPSE_MEMO: std::cell::RefCell<rustc_hash::FxHashMap<String, Option<String>>> =
892    std::cell::RefCell::new(rustc_hash::FxHashMap::default());
893}
894
895/// Clear the per-thread kpsewhich memo. Called at the start of every
896/// conversion (prepare_session): the persistent (non-harness) cortex_worker
897/// runs many papers per thread, and a cached cwd-relative MISS from paper A
898/// (kpathsea's path spec includes `.`) would wrongly persist into paper B
899/// (PR_READINESS should-fix 12).
900#[cfg(feature = "kpathsea")]
901pub fn clear_kpsewhich_memo() { KPSE_MEMO.with(|m| m.borrow_mut().clear()); }
902
903#[cfg(not(feature = "kpathsea"))]
904pub fn clear_kpsewhich_memo() {}
905
906/// search for a list of candidate names via the external `kpsewhich` utility
907/// returning the first path that is found
908///
909/// Memoized (hits AND misses) per thread, keyed by the candidate list
910/// (Principle 5, closed by the 2026-07-02 perf audit): repeated lookups of
911/// the same missing asset (e.g. a figure referenced by many
912/// `\includegraphics`) otherwise re-probe kpathsea each time — a full
913/// fork-exec per probe on the subprocess-`kpsewhich` backend (portable
914/// builds without linked libkpathsea), a cheaper but nonzero library walk
915/// on the in-process backend. Results are stable for a fixed texmf tree
916/// (the same assumption kpathsea's own ls-R cache makes); the memo clears
917/// per conversion via `clear_kpsewhich_memo`.
918/// Say so, ONCE, when neither backend could be constructed.
919///
920/// A host TeX installation is OPTIONAL here — embedded bindings and dumps
921/// convert self-contained documents perfectly well without one — so this is a
922/// warning, not an error. It exists because a silent dead kpathsea is
923/// indistinguishable from a genuinely missing file: every lookup just reports
924/// `Can't find TeX file X`, which sends users (and us — issue #304) hunting
925/// `TEXINPUTS` instead of the resolver that never came up.
926#[cfg(feature = "kpathsea")]
927pub fn report_unavailable_kpathsea() {
928  static ONCE: std::sync::Once = std::sync::Once::new();
929  ONCE.call_once(|| {
930    let (backend, why) = kpathsea_backend();
931    if backend == KpathseaBackend::Unavailable {
932      crate::Warn!(
933        "kpathsea",
934        "unavailable",
935        s!(
936          "No TeX file resolution ({why}): files from a host texmf tree cannot \
937           be found. Embedded bindings still apply."
938        )
939      );
940    }
941  });
942}
943
944/// Without the `kpathsea` feature there is nothing to report at runtime.
945#[cfg(not(feature = "kpathsea"))]
946pub fn report_unavailable_kpathsea() {}
947
948#[cfg(feature = "kpathsea")]
949pub fn kpsewhich(candidates: &[&str]) -> Option<String> {
950  report_unavailable_kpathsea();
951  let key = candidates.join("\x1f");
952  if let Some(cached) = KPSE_MEMO.with(|m| m.borrow().get(&key).cloned()) {
953    return cached;
954  }
955  let result = kpsewhich_uncached(candidates);
956  KPSE_MEMO.with(|m| {
957    let mut m = m.borrow_mut();
958    // Long-lived processes (--server, test harness): bound the memo rather
959    // than grow without limit; 4096 distinct lookups per epoch is plenty.
960    if m.len() >= 4096 {
961      m.clear();
962    }
963    m.insert(key, result.clone());
964  });
965  result
966}
967
968/// Probe each candidate through one backend, in order, returning the first
969/// hit. Skips bogus bare-extension names and guards the in-process
970/// `guess_format_from_filename` panic.
971#[cfg(feature = "kpathsea")]
972fn find_first_via(kpse: &Kpaths, candidates: &[&str]) -> Option<String> {
973  for candidate in candidates {
974    // kpathsea-0.2.3 panics with "attempt to subtract with overflow" in
975    // `guess_format_from_filename` (lib.rs:92) when `filename.len()` is
976    // shorter than some alt_suffix the format-table holds (the L73 normal-
977    // suffix loop has a `filename.len() > suffix.len()` guard but the L92
978    // alt_suffix loop does NOT). User input like `\usepackage[opt]{}`
979    // produces a `.sty` candidate (empty stem) which trips this. Pre-filter
980    // those: a basename starting with `.` and containing only an extension
981    // is bogus to look up. The catch_unwind below remains as defense-in-
982    // depth. Witnesses: 0711.2664 (`.sty`), cs0503041 (`.sty`).
983    let basename = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate);
984    if basename.starts_with('.') && !basename[1..].contains('.') {
985      continue;
986    }
987    let result =
988      std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(candidate)));
989    if let Ok(Some(path)) = result {
990      return Some(path);
991    }
992  }
993  None
994}
995
996#[cfg(feature = "kpathsea")]
997fn kpsewhich_uncached(candidates: &[&str]) -> Option<String> {
998  KPSE
999    .lock()
1000    .unwrap()
1001    .as_ref()
1002    .and_then(|kpse| find_first_via(kpse, candidates))
1003}
1004
1005/// Without the `kpathsea` feature, file resolution is unavailable — every lookup
1006/// returns `None` (graceful degradation). The host-side proc-macro codegen build
1007/// takes this path; it never resolves TeX files at compile time.
1008#[cfg(not(feature = "kpathsea"))]
1009pub fn kpsewhich(_candidates: &[&str]) -> Option<String> { None }
1010
1011/// check if pathname contains dangerous pieces
1012pub fn is_nasty(file: &str) -> bool { PATHNAME_IS_NASTY_RE.is_match(file) }
1013
1014/// returns the current working directory
1015pub fn cwd() -> String { env::current_dir().unwrap().to_string_lossy().to_string() }
1016
1017#[cfg(test)]
1018mod tests {
1019  use super::*;
1020
1021  /// `relative` is Perl's `pathname_relative` (→ `File::Spec->abs2rel`): a
1022  /// path that is NOT a descendant of `base` must come back as a `../…` path,
1023  /// never the raw absolute path. The strip_prefix port used to leak the
1024  /// absolute path here, which surfaced as a `/mnt/g/…` graphic URL (issue
1025  /// #698) and would do the same for any resource relativized against the
1026  /// source directory (e.g. `xslt.rs`).
1027  #[test]
1028  fn relative_emits_dotdot_for_non_descendant() {
1029    // A bare `/x` is not absolute on Windows (no drive), so prefix one there to
1030    // exercise the abs2rel branch on every platform. Results stay forward-slash.
1031    let abs = |p: &str| {
1032      if cfg!(windows) {
1033        format!("C:{p}")
1034      } else {
1035        p.to_string()
1036      }
1037    };
1038    // Sibling tree: base and target diverge one level up.
1039    assert_eq!(
1040      relative(
1041        &abs("/home/u/proj/A/child/images/pic.svg"),
1042        &abs("/home/u/proj/latexml")
1043      ),
1044      "../A/child/images/pic.svg"
1045    );
1046    // Descendant is unchanged (the case strip_prefix already handled).
1047    assert_eq!(
1048      relative(&abs("/home/u/proj/sub/pic.png"), &abs("/home/u/proj")),
1049      "sub/pic.png"
1050    );
1051    // Cousin that diverges two levels up.
1052    assert_eq!(
1053      relative(&abs("/a/b/c/f.tex"), &abs("/a/x/y")),
1054      "../../b/c/f.tex"
1055    );
1056    // A non-absolute pathname is returned canonicalized, as Perl does.
1057    assert_eq!(relative("sub/pic.png", &abs("/home/u/proj")), "sub/pic.png");
1058    // Empty base short-circuits to the canonical pathname.
1059    assert_eq!(relative(&abs("/a/b/pic.png"), ""), abs("/a/b/pic.png"));
1060  }
1061
1062  /// `choose_kpaths` branches that a real host cannot exercise: a MiKTeX
1063  /// banner, a failed in-process construction, and a TeX-less machine.
1064  #[cfg(feature = "kpathsea")]
1065  mod backend_selection {
1066    use super::*;
1067
1068    /// A real subprocess handle, or `None` where the host has no `kpsewhich`.
1069    fn subprocess() -> Option<Kpaths> { Kpaths::new_subprocess().ok() }
1070
1071    #[test]
1072    fn no_constructible_backend_reports_unavailable() {
1073      let (kpse, backend, why) =
1074        choose_kpaths(None, || Err("no lib"), || Err("no kpsewhich"), |_| true);
1075      assert!(kpse.is_none());
1076      assert_eq!(backend, KpathseaBackend::Unavailable);
1077      assert!(
1078        !why.is_empty(),
1079        "an unavailable backend must explain itself"
1080      );
1081    }
1082
1083    /// The regression this hardening exists for: a failed in-process
1084    /// construction used to `?` out and silently disable ALL file resolution
1085    /// instead of trying the subprocess backend.
1086    #[test]
1087    fn failed_in_process_construction_falls_back_to_subprocess() {
1088      if subprocess().is_none() {
1089        return; // no host kpsewhich — nothing to fall back to
1090      }
1091      let (kpse, backend, _) = choose_kpaths(
1092        None,
1093        || Err("libkpathsea did not initialize"),
1094        Kpaths::new_subprocess,
1095        |_| true,
1096      );
1097      assert!(kpse.is_some(), "must not give up while a kpsewhich exists");
1098      assert_eq!(backend, KpathseaBackend::Subprocess);
1099    }
1100
1101    #[test]
1102    fn miktex_banner_selects_subprocess_without_constructing_in_process() {
1103      if subprocess().is_none() {
1104        return;
1105      }
1106      let (kpse, backend, _) = choose_kpaths(
1107        Some("MiKTeX 24.1"),
1108        || panic!("the in-process backend must not be constructed on a MiKTeX host"),
1109        Kpaths::new_subprocess,
1110        |_| true,
1111      );
1112      assert!(kpse.is_some());
1113      assert_eq!(backend, KpathseaBackend::Subprocess);
1114    }
1115
1116    /// A linked backend that resolves nothing (sentinel miss) is abandoned for
1117    /// the subprocess one.
1118    #[test]
1119    fn sentinel_miss_falls_back_to_subprocess() {
1120      let Some(primary) = subprocess() else { return };
1121      if primary.is_in_process() {
1122        return; // this branch only fires for an in-process primary
1123      }
1124      let (kpse, backend, _) = choose_kpaths(None, Kpaths::new, Kpaths::new_subprocess, |_| false);
1125      assert!(kpse.is_some());
1126      assert_eq!(backend, KpathseaBackend::Subprocess);
1127    }
1128  }
1129
1130  /// No backend may conjure a hit for a name that cannot exist.
1131  #[cfg(feature = "kpathsea")]
1132  #[test]
1133  fn absent_names_resolve_to_none() {
1134    assert!(kpsewhich(&["lxo_definitely_absent_probe_304.tex"]).is_none());
1135  }
1136
1137  /// The backend chosen by `select_kpaths` must resolve a universal host
1138  /// file on whatever distribution is ambient — proving the shipped binary
1139  /// works out of the box on both TeX Live (in-process) and MiKTeX (subprocess
1140  /// fallback, since the linked libkpathsea can't read MiKTeX's fndb). Asks
1141  /// through the process-global `KPSE` handle (never constructs a second
1142  /// `Kpaths` — see the carve-out note). Skips cleanly when no TeX toolchain is
1143  /// present (e.g. a bare CI runner), so it can't spuriously fail there.
1144  #[cfg(feature = "kpathsea")]
1145  #[test]
1146  fn selected_backend_resolves_host_files() {
1147    let cmr = kpsewhich(&["cmr10.tfm"]);
1148    if cmr.is_none() && kpsewhich(&["article.cls"]).is_none() {
1149      return; // no TeX toolchain in this environment — nothing to assert
1150    }
1151    let in_process = KPSE
1152      .lock()
1153      .unwrap()
1154      .as_ref()
1155      .map(|k| k.is_in_process())
1156      .unwrap_or(false);
1157    assert!(
1158      cmr.is_some(),
1159      "selected kpathsea backend failed to resolve the universal cmr10.tfm \
1160       (in_process={in_process}); a MiKTeX host must fall back to subprocess"
1161    );
1162  }
1163
1164  fn pathsearch_tmproot(tag: &str) -> PathBuf {
1165    let mut d = env::temp_dir();
1166    d.push(format!("lxo_pathsearch_{}_{}", std::process::id(), tag));
1167    let _ = std::fs::remove_dir_all(&d);
1168    std::fs::create_dir_all(&d).unwrap();
1169    d
1170  }
1171
1172  /// A `--path` ending in `//` searches the directory tree RECURSIVELY
1173  /// (kpsewhich convention); a plain path searches only that directory.
1174  #[test]
1175  fn recursive_double_slash_descends_into_subdirs() {
1176    let root = pathsearch_tmproot("rec");
1177    let deep = root.join("a").join("b");
1178    std::fs::create_dir_all(&deep).unwrap();
1179    std::fs::write(deep.join("target.tex"), "x").unwrap();
1180    let root_s = root.to_str().unwrap().replace('\\', "/");
1181
1182    let found = find("target.tex", PathnameFindOptions {
1183      paths: Some(vec![format!("{root_s}//")]),
1184      ..Default::default()
1185    });
1186    assert!(
1187      found.as_deref().is_some_and(|p| p.ends_with("target.tex")),
1188      "recursive `//` should find the nested target.tex, got {found:?}"
1189    );
1190
1191    let flat = find("target.tex", PathnameFindOptions {
1192      paths: Some(vec![root_s]),
1193      ..Default::default()
1194    });
1195    assert!(
1196      flat.is_none(),
1197      "a plain (non-`//`) path must NOT descend into subdirectories, got {flat:?}"
1198    );
1199    let _ = std::fs::remove_dir_all(&root);
1200  }
1201
1202  /// The directory named directly by a `--path` (no `//`) is still searched —
1203  /// recursion is opt-in, not a regression of the flat case.
1204  #[test]
1205  fn plain_path_finds_file_in_that_dir() {
1206    let root = pathsearch_tmproot("flat");
1207    std::fs::write(root.join("here.tex"), "x").unwrap();
1208    let root_s = root.to_str().unwrap().replace('\\', "/");
1209    let found = find("here.tex", PathnameFindOptions {
1210      paths: Some(vec![root_s]),
1211      ..Default::default()
1212    });
1213    assert!(
1214      found.as_deref().is_some_and(|p| p.ends_with("here.tex")),
1215      "plain path should find a file directly in it, got {found:?}"
1216    );
1217    let _ = std::fs::remove_dir_all(&root);
1218  }
1219
1220  #[test]
1221  fn is_url_schemes() {
1222    // Perl `pathname_is_url`: `=~ /^($PROTOCOL_RE)/` — the protocol must be
1223    // ANCHORED at the start; a bare host (no /path) still matches.
1224    assert!(is_url("http://example.com/path"));
1225    assert!(is_url("http://example.com/path/file.tex"));
1226    assert!(is_url("ftp://host/file"));
1227    assert!(is_url("https://example.com")); // bare host — Perl matches the prefix
1228    assert!(!is_url("plain/path/file.tex"));
1229    assert!(!is_url("/absolute/path"));
1230    // A filename that merely CONTAINS a protocol mid-string is NOT a URL —
1231    // the old `^\w+://…` matched `myers_http://…` via its leading `\w+`,
1232    // wrongly resolving a JabRef `\bibAnnoteFile` key as an existing URL
1233    // (witness 1509.01434, "Script _").
1234    assert!(!is_url(
1235      "myers_http://www.mscs.dal.ca/myers/welcome.html_2014"
1236    ));
1237    assert!(!is_url("foo_ftp://bar/baz"));
1238  }
1239
1240  #[test]
1241  fn is_literaldata_prefix() {
1242    assert!(is_literaldata("literal:foo"));
1243    assert!(!is_literaldata("file:foo"));
1244    assert!(!is_literaldata("plain"));
1245  }
1246
1247  #[test]
1248  fn is_raw_tex_extensions() {
1249    assert!(is_raw("main.tex"));
1250    assert!(is_raw("hyphen.cfg"));
1251    assert!(is_raw("T1enc.def"));
1252    assert!(is_raw("article.cls"));
1253    assert!(is_raw("french.ldf"));
1254    assert!(!is_raw("foo.pdf"));
1255    assert!(!is_raw("bar.png"));
1256    assert!(!is_raw("baz"));
1257  }
1258
1259  #[test]
1260  fn is_reloadable_only_ldf() {
1261    assert!(is_reloadable("french.ldf"));
1262    assert!(!is_reloadable("main.tex"));
1263    assert!(!is_reloadable("foo.sty"));
1264    assert!(!is_reloadable("baz"));
1265  }
1266
1267  #[test]
1268  fn extension_basic() {
1269    assert_eq!(extension("foo.tex"), "tex");
1270    assert_eq!(extension("path/to/main.cls"), "cls");
1271    assert_eq!(extension("no_ext"), "");
1272    assert_eq!(extension("double.dot.ext"), "ext");
1273  }
1274
1275  #[test]
1276  fn file_name_strips_dirs() {
1277    assert_eq!(file_name("path/to/foo.tex"), "foo.tex");
1278    assert_eq!(file_name("foo.tex"), "foo.tex");
1279    assert_eq!(file_name("/abs/path/foo.tex"), "foo.tex");
1280  }
1281
1282  #[test]
1283  fn file_stem_strips_ext() {
1284    assert_eq!(file_stem("foo.tex"), "foo");
1285    assert_eq!(file_stem("path/to/foo.cls"), "foo");
1286    assert_eq!(file_stem("no_ext"), "no_ext");
1287  }
1288
1289  #[test]
1290  fn directory_returns_dir() {
1291    assert_eq!(directory("path/to/foo.tex"), "path/to");
1292    assert!(
1293      directory("foo.tex").is_empty() || directory("foo.tex") == ".",
1294      "relative-only filename: dir is empty or '.'"
1295    );
1296  }
1297
1298  #[test]
1299  fn make_reassembles_components() {
1300    let p = make(Some("path"), Some("foo"), Some("tex"));
1301    assert_eq!(p, "path/foo.tex");
1302  }
1303
1304  #[test]
1305  fn make_none_dir() {
1306    let p = make(None, Some("foo"), Some("tex"));
1307    // No directory → no leading slash.
1308    assert_eq!(p, "foo.tex");
1309  }
1310
1311  #[test]
1312  fn concat_joins_with_slash() {
1313    assert_eq!(concat("path", "foo.tex"), "path/foo.tex");
1314    assert_eq!(concat("a/b", "c.tex"), "a/b/c.tex");
1315  }
1316
1317  #[test]
1318  fn url_split_basic() {
1319    // URL_RE captures groups: 1=host+dir, 2=filename.
1320    let (base, file) = url_split("http://example.com/path/file.tex");
1321    assert_eq!(base, "example.com/path");
1322    assert_eq!(file, "file.tex");
1323  }
1324
1325  #[test]
1326  fn url_split_non_url_gets_index() {
1327    // Non-URL input falls back to (input, "index.tex").
1328    let (proto, rest) = url_split("plain_string");
1329    assert_eq!(proto, "plain_string");
1330    assert_eq!(rest, "index.tex");
1331  }
1332
1333  #[test]
1334  fn split_basic_path() {
1335    let (d, n, e) = split("path/to/foo.tex");
1336    assert_eq!(d, "path/to");
1337    assert_eq!(n, "foo");
1338    assert_eq!(e, "tex");
1339  }
1340
1341  #[test]
1342  fn split_no_ext() {
1343    let (_d, n, e) = split("foo");
1344    assert_eq!(n, "foo");
1345    assert_eq!(e, "");
1346  }
1347
1348  #[test]
1349  fn is_nasty_detects_bad_patterns() {
1350    // Consult the regex; typical "nasty" is path traversal `..` or
1351    // shell chars.
1352    let has_dotdot = is_nasty("path/../bad");
1353    let safe = is_nasty("foo.tex");
1354    // Whatever the regex is, path traversal should be flagged and a
1355    // plain filename should not.
1356    assert!(!safe, "plain filename should not be nasty");
1357    // Keep the dotdot assertion loose — the exact patterns are
1358    // implementation-defined.
1359    let _ = has_dotdot;
1360  }
1361}