use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize};
use crate::config::TexmfConfig;
const INDEXED_EXTS: &[&str] = &["sty", "cls", "dtx"];
const TEXMF_VARS: &[&str] = &["TEXMFHOME", "TEXMFLOCAL", "TEXMFDIST", "TEXMFMAIN"];
#[derive(Debug, Default, Clone)]
pub struct TexmfIndex {
by_name: HashMap<String, PathBuf>,
sty_stems: Vec<String>,
cls_stems: Vec<String>,
}
impl TexmfIndex {
pub fn build_from_roots(roots: &[PathBuf]) -> Self {
let mut by_name: HashMap<String, PathBuf> = HashMap::new();
for root in roots {
match read_ls_r(root) {
Some(entries) => {
for (name, path) in entries {
by_name.entry(name).or_insert(path);
}
}
None => walk_root(root, &mut by_name),
}
}
Self::from_files(by_name)
}
fn from_files(by_name: HashMap<String, PathBuf>) -> Self {
let sty_stems = sorted_stems(&by_name, "sty");
let cls_stems = sorted_stems(&by_name, "cls");
Self {
by_name,
sty_stems,
cls_stems,
}
}
pub fn resolve(&self, stem: &str, exts: &[&str]) -> Option<&Path> {
for ext in exts {
if let Some(path) = self.by_name.get(&format!("{stem}.{ext}")) {
return Some(path);
}
}
None
}
pub fn sty_stems(&self) -> &[String] {
&self.sty_stems
}
pub fn cls_stems(&self) -> &[String] {
&self.cls_stems
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
}
pub fn global_index(config: &TexmfConfig) -> &'static TexmfIndex {
static EMPTY: OnceLock<TexmfIndex> = OnceLock::new();
static GLOBAL: OnceLock<TexmfIndex> = OnceLock::new();
if !config.enabled {
return EMPTY.get_or_init(TexmfIndex::default);
}
GLOBAL.get_or_init(|| load_or_build(config))
}
fn load_or_build(config: &TexmfConfig) -> TexmfIndex {
let roots = discover_roots(config);
if roots.is_empty() {
return TexmfIndex::default();
}
let fingerprint = fingerprint(&roots);
if let Some(files) = load_cache(&fingerprint) {
return TexmfIndex::from_files(files);
}
let index = TexmfIndex::build_from_roots(&roots);
save_cache(&fingerprint, &index.by_name);
index
}
fn discover_roots(config: &TexmfConfig) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
let push = |roots: &mut Vec<PathBuf>, dir: PathBuf| {
if dir.is_dir() && !roots.contains(&dir) {
roots.push(dir);
}
};
for extra in &config.roots {
push(&mut roots, extra.clone());
}
if config.use_kpsewhich {
for var in TEXMF_VARS {
if let Some(dir) = kpsewhich_var(var) {
push(&mut roots, dir);
}
}
}
if roots.is_empty() {
for dir in heuristic_roots() {
push(&mut roots, dir);
}
}
roots
}
fn kpsewhich_var(var: &str) -> Option<PathBuf> {
let output = Command::new("kpsewhich")
.arg(format!("-var-value={var}"))
.output()
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn heuristic_roots() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(home) = dirs::home_dir() {
out.push(home.join("texmf"));
}
out.push(PathBuf::from("/usr/share/texmf-dist"));
out.push(PathBuf::from("/usr/local/share/texmf-dist"));
out.push(PathBuf::from("/usr/share/texmf"));
out
}
fn fingerprint(roots: &[PathBuf]) -> String {
let mut parts = Vec::new();
for root in roots {
let ls_r = root.join("ls-R");
let anchor = if ls_r.is_file() { ls_r } else { root.clone() };
let secs = anchor
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
parts.push(format!("{}@{}", root.display(), secs));
}
parts.join(";")
}
fn cache_path() -> Option<PathBuf> {
Some(dirs::cache_dir()?.join("badness").join("texmf-index.json"))
}
fn load_cache(fingerprint: &str) -> Option<HashMap<String, PathBuf>> {
load_cache_from(&cache_path()?, fingerprint)
}
fn save_cache(fingerprint: &str, files: &HashMap<String, PathBuf>) {
let Some(path) = cache_path() else { return };
save_cache_to(&path, fingerprint, files);
}
fn load_cache_from(path: &Path, fingerprint: &str) -> Option<HashMap<String, PathBuf>> {
let text = std::fs::read_to_string(path).ok()?;
let cached: CachedIndex = serde_json::from_str(&text).ok()?;
(cached.fingerprint == fingerprint).then_some(cached.files)
}
fn save_cache_to(path: &Path, fingerprint: &str, files: &HashMap<String, PathBuf>) {
let cached = CachedIndex {
fingerprint: fingerprint.to_string(),
files: files.clone(),
};
let Ok(text) = serde_json::to_string(&cached) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, text);
}
#[derive(Serialize, Deserialize)]
struct CachedIndex {
fingerprint: String,
files: HashMap<String, PathBuf>,
}
fn read_ls_r(root: &Path) -> Option<Vec<(String, PathBuf)>> {
let text = std::fs::read_to_string(root.join("ls-R")).ok()?;
let mut out = Vec::new();
let mut dir = PathBuf::from(root);
for line in text.lines() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('%') {
continue;
}
if let Some(rel) = line.strip_suffix(':') {
dir = root.join(rel.trim_start_matches("./"));
} else if has_indexed_ext(line) {
out.push((line.to_string(), dir.join(line)));
}
}
Some(out)
}
fn walk_root(root: &Path, by_name: &mut HashMap<String, PathBuf>) {
if !root.is_dir() {
return;
}
for entry in ignore::WalkBuilder::new(root)
.standard_filters(false)
.build()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& has_indexed_ext(name)
{
by_name
.entry(name.to_string())
.or_insert_with(|| path.to_path_buf());
}
}
}
fn has_indexed_ext(filename: &str) -> bool {
Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| INDEXED_EXTS.contains(&e.as_str()))
}
fn sorted_stems(by_name: &HashMap<String, PathBuf>, ext: &str) -> Vec<String> {
let suffix = format!(".{ext}");
let mut stems: Vec<String> = by_name
.keys()
.filter_map(|name| name.strip_suffix(&suffix))
.map(str::to_string)
.collect();
stems.sort_unstable();
stems.dedup();
stems
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let latex = dir.path().join("tex/latex");
std::fs::create_dir_all(latex.join("amsmath")).unwrap();
std::fs::create_dir_all(latex.join("koma-script")).unwrap();
std::fs::write(latex.join("amsmath/amsmath.sty"), "").unwrap();
std::fs::write(latex.join("amsmath/amstext.sty"), "").unwrap();
std::fs::write(latex.join("koma-script/scrartcl.cls"), "").unwrap();
std::fs::write(latex.join("amsmath/amsmath.pdf"), "").unwrap();
dir
}
#[test]
fn walk_indexes_sty_and_cls_by_name() {
let dir = fixture_tree();
let index = TexmfIndex::build_from_roots(&[dir.path().to_path_buf()]);
assert!(!index.is_empty());
assert_eq!(
index.resolve("amsmath", &["sty"]),
Some(dir.path().join("tex/latex/amsmath/amsmath.sty").as_path())
);
assert_eq!(
index.resolve("scrartcl", &["cls"]),
Some(
dir.path()
.join("tex/latex/koma-script/scrartcl.cls")
.as_path()
)
);
assert!(index.resolve("amsmath", &["pdf"]).is_none());
assert_eq!(index.sty_stems(), &["amsmath", "amstext"]);
assert_eq!(index.cls_stems(), &["scrartcl"]);
}
#[test]
fn ls_r_is_preferred_over_walking() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("ls-R"),
"% ls-R -- filename database\n./tex/latex/booktabs:\nbooktabs.sty\n",
)
.unwrap();
let index = TexmfIndex::build_from_roots(&[dir.path().to_path_buf()]);
assert_eq!(
index.resolve("booktabs", &["sty"]),
Some(dir.path().join("tex/latex/booktabs/booktabs.sty").as_path())
);
}
#[test]
fn resolve_tries_extensions_in_order() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("d")).unwrap();
std::fs::write(dir.path().join("d/foo.dtx"), "").unwrap();
let index = TexmfIndex::build_from_roots(&[dir.path().to_path_buf()]);
assert_eq!(
index.resolve("foo", &["sty", "dtx"]),
Some(dir.path().join("d/foo.dtx").as_path())
);
}
#[test]
fn cache_round_trips_and_invalidates_on_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("badness/texmf-index.json");
let files: HashMap<String, PathBuf> =
HashMap::from([("amsmath.sty".to_string(), PathBuf::from("/t/amsmath.sty"))]);
save_cache_to(&path, "fingerprint-abc", &files);
assert_eq!(
load_cache_from(&path, "fingerprint-abc"),
Some(files.clone())
);
assert_eq!(load_cache_from(&path, "fingerprint-xyz"), None);
}
}