use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock, PoisonError};
const KPATHSEP: char = if cfg!(windows) { ';' } else { ':' };
fn normalize_drive_letter(path: &mut String) {
let bytes = path.as_bytes();
if cfg!(windows) && bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_uppercase() {
let lower = (bytes[0] as char).to_ascii_lowercase().to_string();
path.replace_range(..1, &lower);
}
}
type LsRCache = HashMap<String, String>;
struct SharedKpse {
lsr: LsRCache,
memo: Mutex<HashMap<String, Option<String>>>,
installer_args: Vec<String>,
}
static KPSE_REGISTRY: LazyLock<Mutex<HashMap<PathBuf, Arc<SharedKpse>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn shared_kpse(kpsewhich: &Path) -> Arc<SharedKpse> {
if let Some(shared) = KPSE_REGISTRY
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(kpsewhich)
{
return Arc::clone(shared);
}
let built = Arc::new(SharedKpse {
lsr: build_kpse_cache(kpsewhich),
memo: Mutex::new(HashMap::new()),
installer_args: probe_installer_args(kpsewhich),
});
let mut registry = KPSE_REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
Arc::clone(registry.entry(kpsewhich.to_path_buf()).or_insert(built))
}
pub(crate) struct SubprocessKpse {
kpsewhich: PathBuf,
shared: OnceLock<Arc<SharedKpse>>,
}
impl SubprocessKpse {
pub(crate) fn new() -> crate::Result<Self> {
Ok(Self::with_kpsewhich(crate::kpsewhich_executable()?))
}
pub(crate) fn with_kpsewhich(path: PathBuf) -> Self {
SubprocessKpse {
kpsewhich: path,
shared: OnceLock::new(),
}
}
fn shared(&self) -> &SharedKpse {
self.shared.get_or_init(|| shared_kpse(&self.kpsewhich))
}
fn cache_lookup(&self, candidates: &[&str]) -> Option<String> {
let cache = &self.shared().lsr;
candidates.iter().find_map(|c| cache.get(*c).cloned())
}
pub(crate) fn find_first(&self, candidates: &[&str]) -> Option<String> {
if let Some(hit) = self.cache_lookup(candidates) {
return Some(hit);
}
self.run_kpsewhich(&[], candidates)
}
pub(crate) fn find_with_format_name(
&self,
name: &str,
format_name: Option<&str>,
) -> Option<String> {
if let Some(hit) = self.cache_lookup(&[name]) {
return Some(hit);
}
match format_name {
Some(fmt) => self.run_kpsewhich(&[&format!("--format={fmt}")], &[name]),
None => self.run_kpsewhich(&[], &[name]),
}
}
fn run_kpsewhich(&self, flags: &[&str], names: &[&str]) -> Option<String> {
let names: Vec<&str> = names
.iter()
.copied()
.filter(|n| !n.starts_with('-'))
.collect();
if names.is_empty() {
return None;
}
let key = flags
.iter()
.chain(names.iter())
.copied()
.collect::<Vec<_>>()
.join("\u{1f}");
if let Some(outcome) = self.memo().get(&key) {
return outcome.clone();
}
let result = Command::new(&self.kpsewhich)
.args(&self.shared().installer_args)
.args(flags)
.args(&names)
.output()
.ok()
.and_then(|out| {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
});
self.memo().insert(key, result.clone());
result
}
fn memo(&self) -> MutexGuard<'_, HashMap<String, Option<String>>> {
self
.shared()
.memo
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
}
fn probe_installer_args(kpsewhich: &Path) -> Vec<String> {
let accepts = Command::new(kpsewhich)
.arg("--miktex-disable-installer")
.arg("--version")
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if accepts {
vec!["--miktex-disable-installer".to_string()]
} else {
Vec::new()
}
}
fn build_kpse_cache(kpsewhich: &Path) -> HashMap<String, String> {
let mut cache = HashMap::new();
let Ok(out) = Command::new(kpsewhich)
.args(["--expand-var", "$TEXMF", "--show-path", "tex"])
.output()
else {
return cache;
};
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines();
let texmf = lines.next().unwrap_or("").trim().to_string();
let texpaths = lines.next().unwrap_or("").trim().to_string();
let mut filters: Vec<String> = Vec::new();
for path in texpaths.split(KPATHSEP) {
let mut path = path.trim().trim_start_matches("!!").to_string();
while path.ends_with("//") {
path.pop();
}
normalize_drive_letter(&mut path);
if !path.is_empty() && Path::new(&path).is_dir() {
filters.push(path);
}
}
if filters.is_empty() {
return cache;
}
let mut texmf = texmf
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.trim_start_matches('\\')
.to_string();
if texmf.starts_with('{') && texmf.ends_with('}') {
texmf = texmf[1..texmf.len() - 1].to_string();
}
texmf = texmf.replace("{}", "");
let mut ambiguous: std::collections::HashSet<String> = std::collections::HashSet::new();
for dir in texmf.split(',') {
let mut dir = dir.trim().trim_start_matches("!!").to_string();
normalize_drive_letter(&mut dir);
let lsr_path = Path::new(&dir).join("ls-R");
let Ok(lsr) = std::fs::read_to_string(&lsr_path) else {
continue;
};
let mut subdir = String::new();
let mut skip = true; for line in lsr.lines() {
if line.is_empty() || line.starts_with('%') {
continue;
}
if let Some(sub) = line.strip_suffix(':') {
subdir = sub.strip_prefix("./").unwrap_or(sub).to_string();
let d = format!("{dir}/{subdir}");
skip = !filters.iter().any(|f| d.contains(f.as_str()));
skip = skip || d.contains("-dev/") || d.ends_with("-dev");
} else if !skip {
match cache.entry(line.to_string()) {
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(format!("{dir}/{subdir}/{line}"));
}
std::collections::hash_map::Entry::Occupied(_) => {
ambiguous.insert(line.to_string());
}
}
}
}
}
for name in &ambiguous {
cache.remove(name);
}
cache
}
#[cfg(all(test, windows))]
mod tests {
#[test]
fn drive_letters_normalize_to_lowercase() {
let mut p = String::from("D:/texlive/2026/texmf-dist");
super::normalize_drive_letter(&mut p);
assert_eq!(p, "d:/texlive/2026/texmf-dist");
let mut rel = String::from("texmf-dist");
super::normalize_drive_letter(&mut rel);
assert_eq!(rel, "texmf-dist");
}
}