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#[derive(Debug, Clone, Default)]
21pub struct PathnameFindOptions {
22 pub paths: Option<Vec<String>>,
24 pub extensions: Option<Vec<String>>,
26 pub installation_subdir: Option<String>,
28}
29
30static LITERAL_PROTOCOL: &str = "literal:";
31static HOME_TILDE: &str = "~";
32static 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());
42static PATHNAME_IS_NASTY_RE: Lazy<Regex> =
50 Lazy::new(|| Regex::new(r#"[`$;|<>"\x00\n\r]"#).unwrap());
51static URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\w+://(.+)/([^/]+)$").unwrap());
53static URL_PREFIX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?:https|http|ftp):").unwrap());
62
63#[cfg(feature = "kpathsea")]
80static KPSE: Lazy<Mutex<Option<Kpaths>>> = Lazy::new(|| Mutex::new(select_kpaths()));
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum KpathseaBackend {
87 InProcess,
89 Subprocess,
91 Unavailable,
93}
94
95impl KpathseaBackend {
96 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#[cfg(feature = "kpathsea")]
108static BACKEND: OnceLock<(KpathseaBackend, &'static str)> = OnceLock::new();
109
110#[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#[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#[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 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 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 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 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#[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 |kpse| kpse.find_file("cmr10.tfm").is_some(),
206 );
207 let _ = BACKEND.set((backend, why));
208 kpse
209}
210
211pub 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#[cfg(feature = "kpathsea")]
264pub fn prewarm_kpathsea() {
265 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 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 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(sentinel)));
302 }
303 });
304}
305
306#[cfg(not(feature = "kpathsea"))]
309pub fn prewarm_kpathsea() {}
310
311static CANONICAL_URL_RE: Lazy<Regex> =
313 Lazy::new(|| Regex::new(r"^((?:https|http|ftp)://[^/]*)").unwrap());
314
315pub fn is_url(path: &str) -> bool { URL_PREFIX_RE.is_match(path) }
339pub fn is_literaldata(data: &str) -> bool { data.starts_with(LITERAL_PROTOCOL) }
341
342pub fn is_reloadable(pathname: &str) -> bool {
344 let (_dir, _name, ext) = split(pathname);
345 ext == "ldf"
349}
350pub 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
359pub fn is_absolute(path: &str) -> bool { Path::new(&canonical(path)).is_absolute() }
361pub 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
386pub 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 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
407pub 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") }
417}
418
419pub fn canonical(pathname: &str) -> String {
422 if is_literaldata(pathname) {
423 return pathname.to_owned();
424 }
425 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 #[cfg(windows)]
442 {
443 if pathname.contains('\\') {
444 pathname = pathname.replace('\\', "/");
445 }
446 }
447
448 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 while pathname.contains("/./") {
459 pathname = pathname.replace("/./", "/");
460 }
461 loop {
465 let mut changed = false;
466 if let Some(dotdot_pos) = pathname.find("/..") {
468 let after = dotdot_pos + 3;
470 if after == pathname.len() || pathname.as_bytes().get(after) == Some(&b'/') {
471 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 let trail = &pathname[after..];
479 pathname = format!("{}{}", &pathname[..slash_pos], trail);
480 changed = true;
481 }
482 } else if prefix != ".." {
483 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 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
512pub 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
523pub 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 canonical(&format!("{dir}/{file}"))
535 }
536}
537
538fn expand_recursive_dirs(base: &str) -> Vec<String> {
545 let mut out = vec![base.to_string()];
546 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 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 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 out.push(s.replace('\\', "/"));
579 queue.push_back(child);
580 }
581 }
582 }
583 out
584}
585
586pub 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 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 if is_absolute(&canonical_pathname) {
610 dirs.push(pathdir.clone());
611 } else if let Some(paths) = options.paths {
612 for p in paths {
613 let (base, recursive) = match p.strip_suffix("//") {
617 Some(b) => (b.trim_end_matches('/'), true),
618 None => (p.as_str(), false),
619 };
620 let pp_base = if is_absolute(base) {
622 canonical(base)
623 } else {
624 concat(&cwd, base)
625 };
626 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 if !dirs.contains(&pp) {
637 dirs.push(pp);
638 }
639 }
640 }
641 }
642 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 if let Some(subdir) = options.installation_subdir {
659 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 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 exts.push(String::new());
686 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 for dir in &dirs {
700 for ext in &exts {
701 if name == "*" {
702 } else {
704 paths.push(concat(dir, &(name.clone() + ext)));
705 }
706 }
707 }
708 paths
709}
710
711pub 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 for path in &paths {
728 if Path::new(path).exists() {
729 return Some(path.clone());
730 }
731 }
732 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
760pub 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
770pub 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
781pub 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
792pub 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
804pub 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
826pub 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
842pub fn findall(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
845 candidate_pathnames(pathname, options)
846}
847
848#[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#[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#[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#[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 if m.len() >= 4096 {
921 m.clear();
922 }
923 m.insert(key, result.clone());
924 });
925 result
926}
927
928#[cfg(feature = "kpathsea")]
932fn find_first_via(kpse: &Kpaths, candidates: &[&str]) -> Option<String> {
933 for candidate in candidates {
934 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#[cfg(not(feature = "kpathsea"))]
969pub fn kpsewhich(_candidates: &[&str]) -> Option<String> { None }
970
971pub fn is_nasty(file: &str) -> bool { PATHNAME_IS_NASTY_RE.is_match(file) }
973
974pub fn cwd() -> String { env::current_dir().unwrap().to_string_lossy().to_string() }
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[cfg(feature = "kpathsea")]
984 mod backend_selection {
985 use super::*;
986
987 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 #[test]
1006 fn failed_in_process_construction_falls_back_to_subprocess() {
1007 if subprocess().is_none() {
1008 return; }
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 #[test]
1038 fn sentinel_miss_falls_back_to_subprocess() {
1039 let Some(primary) = subprocess() else { return };
1040 if primary.is_in_process() {
1041 return; }
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 #[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 #[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; }
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 #[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 #[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 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")); assert!(!is_url("plain/path/file.tex"));
1148 assert!(!is_url("/absolute/path"));
1149 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 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 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 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 let has_dotdot = is_nasty("path/../bad");
1272 let safe = is_nasty("foo.tex");
1273 assert!(!safe, "plain filename should not be nasty");
1276 let _ = has_dotdot;
1279 }
1280}