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/// Make a pathname relative to a base directory.
827/// Port of Perl's pathname_relative($pathname, $base).
828pub fn relative(pathname: &str, base: &str) -> String {
829  let canonical_pathname = canonical(pathname);
830  if base.is_empty() || !is_absolute(&canonical_pathname) {
831    return canonical_pathname;
832  }
833  let canonical_base = canonical(base);
834  let path = Path::new(&canonical_pathname);
835  let base_path = Path::new(&canonical_base);
836  match path.strip_prefix(base_path) {
837    Ok(rel) => rel.to_string_lossy().to_string(),
838    Err(_) => canonical_pathname,
839  }
840}
841
842/// Find all matching files (like pathname_findall).
843/// Port of Perl's pathname_findall($pathname, %options).
844pub fn findall(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
845  candidate_pathnames(pathname, options)
846}
847
848// Memo store for `kpsewhich` (see its doc below).
849#[cfg(feature = "kpathsea")]
850std::thread_local! {
851  static KPSE_MEMO: std::cell::RefCell<rustc_hash::FxHashMap<String, Option<String>>> =
852    std::cell::RefCell::new(rustc_hash::FxHashMap::default());
853}
854
855/// Clear the per-thread kpsewhich memo. Called at the start of every
856/// conversion (prepare_session): the persistent (non-harness) cortex_worker
857/// runs many papers per thread, and a cached cwd-relative MISS from paper A
858/// (kpathsea's path spec includes `.`) would wrongly persist into paper B
859/// (PR_READINESS should-fix 12).
860#[cfg(feature = "kpathsea")]
861pub fn clear_kpsewhich_memo() { KPSE_MEMO.with(|m| m.borrow_mut().clear()); }
862
863#[cfg(not(feature = "kpathsea"))]
864pub fn clear_kpsewhich_memo() {}
865
866/// search for a list of candidate names via the external `kpsewhich` utility
867/// returning the first path that is found
868///
869/// Memoized (hits AND misses) per thread, keyed by the candidate list
870/// (Principle 5, closed by the 2026-07-02 perf audit): repeated lookups of
871/// the same missing asset (e.g. a figure referenced by many
872/// `\includegraphics`) otherwise re-probe kpathsea each time — a full
873/// fork-exec per probe on the subprocess-`kpsewhich` backend (portable
874/// builds without linked libkpathsea), a cheaper but nonzero library walk
875/// on the in-process backend. Results are stable for a fixed texmf tree
876/// (the same assumption kpathsea's own ls-R cache makes); the memo clears
877/// per conversion via `clear_kpsewhich_memo`.
878/// Say so, ONCE, when neither backend could be constructed.
879///
880/// A host TeX installation is OPTIONAL here — embedded bindings and dumps
881/// convert self-contained documents perfectly well without one — so this is a
882/// warning, not an error. It exists because a silent dead kpathsea is
883/// indistinguishable from a genuinely missing file: every lookup just reports
884/// `Can't find TeX file X`, which sends users (and us — issue #304) hunting
885/// `TEXINPUTS` instead of the resolver that never came up.
886#[cfg(feature = "kpathsea")]
887pub fn report_unavailable_kpathsea() {
888  static ONCE: std::sync::Once = std::sync::Once::new();
889  ONCE.call_once(|| {
890    let (backend, why) = kpathsea_backend();
891    if backend == KpathseaBackend::Unavailable {
892      crate::Warn!(
893        "kpathsea",
894        "unavailable",
895        s!(
896          "No TeX file resolution ({why}): files from a host texmf tree cannot \
897           be found. Embedded bindings still apply."
898        )
899      );
900    }
901  });
902}
903
904/// Without the `kpathsea` feature there is nothing to report at runtime.
905#[cfg(not(feature = "kpathsea"))]
906pub fn report_unavailable_kpathsea() {}
907
908#[cfg(feature = "kpathsea")]
909pub fn kpsewhich(candidates: &[&str]) -> Option<String> {
910  report_unavailable_kpathsea();
911  let key = candidates.join("\x1f");
912  if let Some(cached) = KPSE_MEMO.with(|m| m.borrow().get(&key).cloned()) {
913    return cached;
914  }
915  let result = kpsewhich_uncached(candidates);
916  KPSE_MEMO.with(|m| {
917    let mut m = m.borrow_mut();
918    // Long-lived processes (--server, test harness): bound the memo rather
919    // than grow without limit; 4096 distinct lookups per epoch is plenty.
920    if m.len() >= 4096 {
921      m.clear();
922    }
923    m.insert(key, result.clone());
924  });
925  result
926}
927
928/// Probe each candidate through one backend, in order, returning the first
929/// hit. Skips bogus bare-extension names and guards the in-process
930/// `guess_format_from_filename` panic.
931#[cfg(feature = "kpathsea")]
932fn find_first_via(kpse: &Kpaths, candidates: &[&str]) -> Option<String> {
933  for candidate in candidates {
934    // kpathsea-0.2.3 panics with "attempt to subtract with overflow" in
935    // `guess_format_from_filename` (lib.rs:92) when `filename.len()` is
936    // shorter than some alt_suffix the format-table holds (the L73 normal-
937    // suffix loop has a `filename.len() > suffix.len()` guard but the L92
938    // alt_suffix loop does NOT). User input like `\usepackage[opt]{}`
939    // produces a `.sty` candidate (empty stem) which trips this. Pre-filter
940    // those: a basename starting with `.` and containing only an extension
941    // is bogus to look up. The catch_unwind below remains as defense-in-
942    // depth. Witnesses: 0711.2664 (`.sty`), cs0503041 (`.sty`).
943    let basename = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate);
944    if basename.starts_with('.') && !basename[1..].contains('.') {
945      continue;
946    }
947    let result =
948      std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(candidate)));
949    if let Ok(Some(path)) = result {
950      return Some(path);
951    }
952  }
953  None
954}
955
956#[cfg(feature = "kpathsea")]
957fn kpsewhich_uncached(candidates: &[&str]) -> Option<String> {
958  KPSE
959    .lock()
960    .unwrap()
961    .as_ref()
962    .and_then(|kpse| find_first_via(kpse, candidates))
963}
964
965/// Without the `kpathsea` feature, file resolution is unavailable — every lookup
966/// returns `None` (graceful degradation). The host-side proc-macro codegen build
967/// takes this path; it never resolves TeX files at compile time.
968#[cfg(not(feature = "kpathsea"))]
969pub fn kpsewhich(_candidates: &[&str]) -> Option<String> { None }
970
971/// check if pathname contains dangerous pieces
972pub fn is_nasty(file: &str) -> bool { PATHNAME_IS_NASTY_RE.is_match(file) }
973
974/// returns the current working directory
975pub fn cwd() -> String { env::current_dir().unwrap().to_string_lossy().to_string() }
976
977#[cfg(test)]
978mod tests {
979  use super::*;
980
981  /// `choose_kpaths` branches that a real host cannot exercise: a MiKTeX
982  /// banner, a failed in-process construction, and a TeX-less machine.
983  #[cfg(feature = "kpathsea")]
984  mod backend_selection {
985    use super::*;
986
987    /// A real subprocess handle, or `None` where the host has no `kpsewhich`.
988    fn subprocess() -> Option<Kpaths> { Kpaths::new_subprocess().ok() }
989
990    #[test]
991    fn no_constructible_backend_reports_unavailable() {
992      let (kpse, backend, why) =
993        choose_kpaths(None, || Err("no lib"), || Err("no kpsewhich"), |_| true);
994      assert!(kpse.is_none());
995      assert_eq!(backend, KpathseaBackend::Unavailable);
996      assert!(
997        !why.is_empty(),
998        "an unavailable backend must explain itself"
999      );
1000    }
1001
1002    /// The regression this hardening exists for: a failed in-process
1003    /// construction used to `?` out and silently disable ALL file resolution
1004    /// instead of trying the subprocess backend.
1005    #[test]
1006    fn failed_in_process_construction_falls_back_to_subprocess() {
1007      if subprocess().is_none() {
1008        return; // no host kpsewhich — nothing to fall back to
1009      }
1010      let (kpse, backend, _) = choose_kpaths(
1011        None,
1012        || Err("libkpathsea did not initialize"),
1013        Kpaths::new_subprocess,
1014        |_| true,
1015      );
1016      assert!(kpse.is_some(), "must not give up while a kpsewhich exists");
1017      assert_eq!(backend, KpathseaBackend::Subprocess);
1018    }
1019
1020    #[test]
1021    fn miktex_banner_selects_subprocess_without_constructing_in_process() {
1022      if subprocess().is_none() {
1023        return;
1024      }
1025      let (kpse, backend, _) = choose_kpaths(
1026        Some("MiKTeX 24.1"),
1027        || panic!("the in-process backend must not be constructed on a MiKTeX host"),
1028        Kpaths::new_subprocess,
1029        |_| true,
1030      );
1031      assert!(kpse.is_some());
1032      assert_eq!(backend, KpathseaBackend::Subprocess);
1033    }
1034
1035    /// A linked backend that resolves nothing (sentinel miss) is abandoned for
1036    /// the subprocess one.
1037    #[test]
1038    fn sentinel_miss_falls_back_to_subprocess() {
1039      let Some(primary) = subprocess() else { return };
1040      if primary.is_in_process() {
1041        return; // this branch only fires for an in-process primary
1042      }
1043      let (kpse, backend, _) = choose_kpaths(None, Kpaths::new, Kpaths::new_subprocess, |_| false);
1044      assert!(kpse.is_some());
1045      assert_eq!(backend, KpathseaBackend::Subprocess);
1046    }
1047  }
1048
1049  /// No backend may conjure a hit for a name that cannot exist.
1050  #[cfg(feature = "kpathsea")]
1051  #[test]
1052  fn absent_names_resolve_to_none() {
1053    assert!(kpsewhich(&["lxo_definitely_absent_probe_304.tex"]).is_none());
1054  }
1055
1056  /// The backend chosen by `select_kpaths` must resolve a universal host
1057  /// file on whatever distribution is ambient — proving the shipped binary
1058  /// works out of the box on both TeX Live (in-process) and MiKTeX (subprocess
1059  /// fallback, since the linked libkpathsea can't read MiKTeX's fndb). Asks
1060  /// through the process-global `KPSE` handle (never constructs a second
1061  /// `Kpaths` — see the carve-out note). Skips cleanly when no TeX toolchain is
1062  /// present (e.g. a bare CI runner), so it can't spuriously fail there.
1063  #[cfg(feature = "kpathsea")]
1064  #[test]
1065  fn selected_backend_resolves_host_files() {
1066    let cmr = kpsewhich(&["cmr10.tfm"]);
1067    if cmr.is_none() && kpsewhich(&["article.cls"]).is_none() {
1068      return; // no TeX toolchain in this environment — nothing to assert
1069    }
1070    let in_process = KPSE
1071      .lock()
1072      .unwrap()
1073      .as_ref()
1074      .map(|k| k.is_in_process())
1075      .unwrap_or(false);
1076    assert!(
1077      cmr.is_some(),
1078      "selected kpathsea backend failed to resolve the universal cmr10.tfm \
1079       (in_process={in_process}); a MiKTeX host must fall back to subprocess"
1080    );
1081  }
1082
1083  fn pathsearch_tmproot(tag: &str) -> PathBuf {
1084    let mut d = env::temp_dir();
1085    d.push(format!("lxo_pathsearch_{}_{}", std::process::id(), tag));
1086    let _ = std::fs::remove_dir_all(&d);
1087    std::fs::create_dir_all(&d).unwrap();
1088    d
1089  }
1090
1091  /// A `--path` ending in `//` searches the directory tree RECURSIVELY
1092  /// (kpsewhich convention); a plain path searches only that directory.
1093  #[test]
1094  fn recursive_double_slash_descends_into_subdirs() {
1095    let root = pathsearch_tmproot("rec");
1096    let deep = root.join("a").join("b");
1097    std::fs::create_dir_all(&deep).unwrap();
1098    std::fs::write(deep.join("target.tex"), "x").unwrap();
1099    let root_s = root.to_str().unwrap().replace('\\', "/");
1100
1101    let found = find("target.tex", PathnameFindOptions {
1102      paths: Some(vec![format!("{root_s}//")]),
1103      ..Default::default()
1104    });
1105    assert!(
1106      found.as_deref().is_some_and(|p| p.ends_with("target.tex")),
1107      "recursive `//` should find the nested target.tex, got {found:?}"
1108    );
1109
1110    let flat = find("target.tex", PathnameFindOptions {
1111      paths: Some(vec![root_s]),
1112      ..Default::default()
1113    });
1114    assert!(
1115      flat.is_none(),
1116      "a plain (non-`//`) path must NOT descend into subdirectories, got {flat:?}"
1117    );
1118    let _ = std::fs::remove_dir_all(&root);
1119  }
1120
1121  /// The directory named directly by a `--path` (no `//`) is still searched —
1122  /// recursion is opt-in, not a regression of the flat case.
1123  #[test]
1124  fn plain_path_finds_file_in_that_dir() {
1125    let root = pathsearch_tmproot("flat");
1126    std::fs::write(root.join("here.tex"), "x").unwrap();
1127    let root_s = root.to_str().unwrap().replace('\\', "/");
1128    let found = find("here.tex", PathnameFindOptions {
1129      paths: Some(vec![root_s]),
1130      ..Default::default()
1131    });
1132    assert!(
1133      found.as_deref().is_some_and(|p| p.ends_with("here.tex")),
1134      "plain path should find a file directly in it, got {found:?}"
1135    );
1136    let _ = std::fs::remove_dir_all(&root);
1137  }
1138
1139  #[test]
1140  fn is_url_schemes() {
1141    // Perl `pathname_is_url`: `=~ /^($PROTOCOL_RE)/` — the protocol must be
1142    // ANCHORED at the start; a bare host (no /path) still matches.
1143    assert!(is_url("http://example.com/path"));
1144    assert!(is_url("http://example.com/path/file.tex"));
1145    assert!(is_url("ftp://host/file"));
1146    assert!(is_url("https://example.com")); // bare host — Perl matches the prefix
1147    assert!(!is_url("plain/path/file.tex"));
1148    assert!(!is_url("/absolute/path"));
1149    // A filename that merely CONTAINS a protocol mid-string is NOT a URL —
1150    // the old `^\w+://…` matched `myers_http://…` via its leading `\w+`,
1151    // wrongly resolving a JabRef `\bibAnnoteFile` key as an existing URL
1152    // (witness 1509.01434, "Script _").
1153    assert!(!is_url(
1154      "myers_http://www.mscs.dal.ca/myers/welcome.html_2014"
1155    ));
1156    assert!(!is_url("foo_ftp://bar/baz"));
1157  }
1158
1159  #[test]
1160  fn is_literaldata_prefix() {
1161    assert!(is_literaldata("literal:foo"));
1162    assert!(!is_literaldata("file:foo"));
1163    assert!(!is_literaldata("plain"));
1164  }
1165
1166  #[test]
1167  fn is_raw_tex_extensions() {
1168    assert!(is_raw("main.tex"));
1169    assert!(is_raw("hyphen.cfg"));
1170    assert!(is_raw("T1enc.def"));
1171    assert!(is_raw("article.cls"));
1172    assert!(is_raw("french.ldf"));
1173    assert!(!is_raw("foo.pdf"));
1174    assert!(!is_raw("bar.png"));
1175    assert!(!is_raw("baz"));
1176  }
1177
1178  #[test]
1179  fn is_reloadable_only_ldf() {
1180    assert!(is_reloadable("french.ldf"));
1181    assert!(!is_reloadable("main.tex"));
1182    assert!(!is_reloadable("foo.sty"));
1183    assert!(!is_reloadable("baz"));
1184  }
1185
1186  #[test]
1187  fn extension_basic() {
1188    assert_eq!(extension("foo.tex"), "tex");
1189    assert_eq!(extension("path/to/main.cls"), "cls");
1190    assert_eq!(extension("no_ext"), "");
1191    assert_eq!(extension("double.dot.ext"), "ext");
1192  }
1193
1194  #[test]
1195  fn file_name_strips_dirs() {
1196    assert_eq!(file_name("path/to/foo.tex"), "foo.tex");
1197    assert_eq!(file_name("foo.tex"), "foo.tex");
1198    assert_eq!(file_name("/abs/path/foo.tex"), "foo.tex");
1199  }
1200
1201  #[test]
1202  fn file_stem_strips_ext() {
1203    assert_eq!(file_stem("foo.tex"), "foo");
1204    assert_eq!(file_stem("path/to/foo.cls"), "foo");
1205    assert_eq!(file_stem("no_ext"), "no_ext");
1206  }
1207
1208  #[test]
1209  fn directory_returns_dir() {
1210    assert_eq!(directory("path/to/foo.tex"), "path/to");
1211    assert!(
1212      directory("foo.tex").is_empty() || directory("foo.tex") == ".",
1213      "relative-only filename: dir is empty or '.'"
1214    );
1215  }
1216
1217  #[test]
1218  fn make_reassembles_components() {
1219    let p = make(Some("path"), Some("foo"), Some("tex"));
1220    assert_eq!(p, "path/foo.tex");
1221  }
1222
1223  #[test]
1224  fn make_none_dir() {
1225    let p = make(None, Some("foo"), Some("tex"));
1226    // No directory → no leading slash.
1227    assert_eq!(p, "foo.tex");
1228  }
1229
1230  #[test]
1231  fn concat_joins_with_slash() {
1232    assert_eq!(concat("path", "foo.tex"), "path/foo.tex");
1233    assert_eq!(concat("a/b", "c.tex"), "a/b/c.tex");
1234  }
1235
1236  #[test]
1237  fn url_split_basic() {
1238    // URL_RE captures groups: 1=host+dir, 2=filename.
1239    let (base, file) = url_split("http://example.com/path/file.tex");
1240    assert_eq!(base, "example.com/path");
1241    assert_eq!(file, "file.tex");
1242  }
1243
1244  #[test]
1245  fn url_split_non_url_gets_index() {
1246    // Non-URL input falls back to (input, "index.tex").
1247    let (proto, rest) = url_split("plain_string");
1248    assert_eq!(proto, "plain_string");
1249    assert_eq!(rest, "index.tex");
1250  }
1251
1252  #[test]
1253  fn split_basic_path() {
1254    let (d, n, e) = split("path/to/foo.tex");
1255    assert_eq!(d, "path/to");
1256    assert_eq!(n, "foo");
1257    assert_eq!(e, "tex");
1258  }
1259
1260  #[test]
1261  fn split_no_ext() {
1262    let (_d, n, e) = split("foo");
1263    assert_eq!(n, "foo");
1264    assert_eq!(e, "");
1265  }
1266
1267  #[test]
1268  fn is_nasty_detects_bad_patterns() {
1269    // Consult the regex; typical "nasty" is path traversal `..` or
1270    // shell chars.
1271    let has_dotdot = is_nasty("path/../bad");
1272    let safe = is_nasty("foo.tex");
1273    // Whatever the regex is, path traversal should be flagged and a
1274    // plain filename should not.
1275    assert!(!safe, "plain filename should not be nasty");
1276    // Keep the dotdot assertion loose — the exact patterns are
1277    // implementation-defined.
1278    let _ = has_dotdot;
1279  }
1280}