Skip to main content

retex_kpath/
lib.rs

1//! A native implementation of the parts of kpathsea that TeX engines use.
2//!
3//! kpathsea finds files in a TeX installation from search paths defined in
4//! `texmf.cnf`. It speeds lookups up with `ls-R` databases. This crate
5//! reproduces its semantics so that retex finds exactly the same files as
6//! `luatex`:
7//!
8//! * `texmf.cnf` files are found through `TEXMFCNF`. Definitions in earlier
9//!   files win. `VAR.progname` takes precedence over `VAR`, and environment
10//!   variables take precedence over both.
11//! * Values are expanded: `$VAR`/`${VAR}`, then braces `{a,b}`, then `~`.
12//! * In a path element, a leading `!!` means "look only in the `ls-R`
13//!   database" and a trailing or inner `//` means "any subdirectory".
14//! * A lookup of a name with no standard suffix first tries the name with each
15//!   suffix of the file format, then the name as given.
16
17#![forbid(unsafe_code)]
18
19mod cnf;
20mod db;
21mod expand;
22mod format;
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27pub use format::FileFormat;
28
29/// A kpathsea instance shared by several users (engine, font loader).
30pub type Shared = std::rc::Rc<std::cell::RefCell<Kpathsea>>;
31
32/// Wraps a [`Kpathsea`] for sharing.
33pub fn shared(k: Kpathsea) -> Shared {
34    std::rc::Rc::new(std::cell::RefCell::new(k))
35}
36
37use cnf::Cnf;
38use db::{DirCache, LsR};
39
40/// Errors raised while setting up the search machinery.
41#[derive(Debug, thiserror::Error)]
42pub enum Error {
43    /// No `texmf.cnf` could be found.
44    #[error("no texmf.cnf found (searched: {0})")]
45    NoConfig(String),
46    /// An I/O error while reading configuration or databases.
47    #[error("reading {path}: {source}")]
48    Io {
49        /// The file being read.
50        path: PathBuf,
51        /// The underlying error.
52        source: std::io::Error,
53    },
54}
55
56/// The default `TEXMFCNF` search path, used when the environment does not
57/// set one. It lists the usual locations of TeX Live and the Debian
58/// packages; `$SELFAUTO…` variables refer to the directory of the
59/// installed `kpsewhich`, when one is found.
60const DEFAULT_TEXMFCNF: &str = "{$SELFAUTOLOC,$SELFAUTOLOC/share/texmf-local/web2c,\
61$SELFAUTOLOC/share/texmf-dist/web2c,$SELFAUTOLOC/share/texmf/web2c,\
62$SELFAUTOLOC/texmf-local/web2c,$SELFAUTOLOC/texmf-dist/web2c,$SELFAUTOLOC/texmf/web2c,\
63$SELFAUTODIR,$SELFAUTODIR/share/texmf-local/web2c,$SELFAUTODIR/share/texmf-dist/web2c,\
64$SELFAUTODIR/share/texmf/web2c,$SELFAUTODIR/texmf-local/web2c,$SELFAUTODIR/texmf-dist/web2c,\
65$SELFAUTODIR/texmf/web2c,$SELFAUTOGRANDPARENT/texmf-local/web2c,$SELFAUTOPARENT,\
66$SELFAUTOPARENT/share/texmf-local/web2c,$SELFAUTOPARENT/share/texmf-dist/web2c,\
67$SELFAUTOPARENT/share/texmf/web2c,$SELFAUTOPARENT/texmf-local/web2c,\
68$SELFAUTOPARENT/texmf-dist/web2c,$SELFAUTOPARENT/texmf/web2c}:\
69/etc/texmf/web2c:/usr/local/share/texmf/web2c:/usr/share/texlive/texmf-dist/web2c";
70
71/// Source of environment variables, injectable for tests.
72pub trait Env {
73    /// Returns the value of the environment variable `name`, if set.
74    fn var(&self, name: &str) -> Option<String>;
75}
76
77/// The process environment.
78#[derive(Debug, Default, Clone, Copy)]
79pub struct ProcessEnv;
80
81impl Env for ProcessEnv {
82    fn var(&self, name: &str) -> Option<String> {
83        std::env::var(name).ok()
84    }
85}
86
87/// Configuration for constructing a [`Kpathsea`] instance.
88#[derive(Debug, Clone)]
89pub struct Options {
90    /// The program name (`$progname`), e.g. `lualatex`.
91    pub progname: String,
92    /// The engine name (`$engine`), e.g. `luahbtex`.
93    pub engine: String,
94    /// The directory standing in for `$SELFAUTOLOC` (the directory containing
95    /// the TeX binaries). `None` locates `kpsewhich` on `PATH`.
96    pub selfautoloc: Option<PathBuf>,
97    /// A directory where parsed `ls-R` databases are cached between runs.
98    pub cache_dir: Option<PathBuf>,
99}
100
101impl Options {
102    /// Options for the `lualatex` program run by the `luahbtex` engine.
103    pub fn lualatex() -> Self {
104        Options {
105            progname: "lualatex".into(),
106            engine: "luahbtex".into(),
107            selfautoloc: None,
108            cache_dir: None,
109        }
110    }
111}
112
113/// A configured kpathsea instance.
114pub struct Kpathsea {
115    cnf: Cnf,
116    env: Box<dyn Env>,
117    progname: String,
118    engine: String,
119    /// Built-in variables (`SELFAUTOLOC`, `progname`, …).
120    builtins: HashMap<String, String>,
121    dbs: Vec<LsR>,
122    dirs: DirCache,
123    /// Expanded search paths, cached per format.
124    paths: HashMap<FileFormat, Vec<String>>,
125    /// Where parsed databases are cached.
126    cache_dir: Option<PathBuf>,
127}
128
129impl std::fmt::Debug for Kpathsea {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("Kpathsea")
132            .field("progname", &self.progname)
133            .field("engine", &self.engine)
134            .finish_non_exhaustive()
135    }
136}
137
138impl Kpathsea {
139    /// Initializes kpathsea from the process environment.
140    pub fn new(options: Options) -> Result<Self, Error> {
141        Self::with_env(options, Box::new(ProcessEnv))
142    }
143
144    /// Initializes kpathsea with a custom environment.
145    pub fn with_env(options: Options, env: Box<dyn Env>) -> Result<Self, Error> {
146        let cache_dir = options.cache_dir.clone();
147        let selfautoloc = options
148            .selfautoloc
149            .clone()
150            .or_else(locate_kpsewhich_dir)
151            .unwrap_or_else(|| PathBuf::from("/usr/bin"));
152        let mut builtins = HashMap::new();
153        let loc = selfautoloc.to_string_lossy().into_owned();
154        let dir = parent(&loc);
155        let par = parent(&dir);
156        let grand = parent(&par);
157        builtins.insert("SELFAUTOLOC".to_owned(), loc);
158        builtins.insert("SELFAUTODIR".to_owned(), dir);
159        builtins.insert("SELFAUTOPARENT".to_owned(), par);
160        builtins.insert("SELFAUTOGRANDPARENT".to_owned(), grand);
161        builtins.insert("progname".to_owned(), options.progname.clone());
162        builtins.insert("engine".to_owned(), options.engine.clone());
163
164        let mut kpse = Kpathsea {
165            cnf: Cnf::default(),
166            env,
167            progname: options.progname,
168            engine: options.engine,
169            builtins,
170            dbs: Vec::new(),
171            dirs: DirCache::default(),
172            paths: HashMap::new(),
173            cache_dir,
174        };
175        let t0 = std::time::Instant::now();
176        kpse.load_cnf()?;
177        let t1 = std::time::Instant::now();
178        kpse.load_dbs()?;
179        if std::env::var_os("RETEX_KPSE_TIMING").is_some() {
180            eprintln!("kpse: cnf {:?}, dbs {:?}", t1 - t0, t1.elapsed());
181        }
182        Ok(kpse)
183    }
184
185    /// The program name.
186    pub fn progname(&self) -> &str {
187        &self.progname
188    }
189
190    /// The engine name.
191    pub fn engine(&self) -> &str {
192        &self.engine
193    }
194
195    fn load_cnf(&mut self) -> Result<(), Error> {
196        let raw = self
197            .env
198            .var("TEXMFCNF")
199            .map(|v| expand::expand_default(&v, DEFAULT_TEXMFCNF))
200            .unwrap_or_else(|| DEFAULT_TEXMFCNF.to_owned());
201        let expanded = self.expand_vars(&raw);
202        let mut found_any = false;
203        for dir in expand::brace_expand_path(&expanded) {
204            let dir = self.expand_tilde(&dir);
205            let file = Path::new(&dir).join("texmf.cnf");
206            match std::fs::read_to_string(&file) {
207                Ok(text) => {
208                    found_any = true;
209                    self.cnf.parse(&text);
210                }
211                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
212                Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => {}
213                Err(source) => return Err(Error::Io { path: file, source }),
214            }
215        }
216        if found_any {
217            Ok(())
218        } else {
219            Err(Error::NoConfig(expanded))
220        }
221    }
222
223    fn load_dbs(&mut self) -> Result<(), Error> {
224        let dbs = self.var_value("TEXMFDBS").unwrap_or_default();
225        let mut seen = Vec::new();
226        for elt in expand::brace_expand_path(&dbs) {
227            let elt = elt.trim_start_matches("!!");
228            let elt = self.expand_tilde(elt);
229            let root = elt.trim_end_matches('/').to_owned();
230            if root.is_empty() || seen.contains(&root) {
231                continue;
232            }
233            seen.push(root.clone());
234            for name in ["ls-R", "ls-r"] {
235                let file = Path::new(&root).join(name);
236                let Ok(meta) = std::fs::metadata(&file) else {
237                    continue;
238                };
239                let mtime = meta
240                    .modified()
241                    .ok()
242                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
243                    .map_or((0, 0), |d| (d.as_secs() as i64, d.subsec_nanos()));
244                let cache_file = self
245                    .cache_dir
246                    .as_ref()
247                    .map(|d| d.join(format!("lsr-{:016x}.idx", content_key(root.as_bytes()))));
248                if let Some(cf) = &cache_file
249                    && let Ok(bytes) = std::fs::read(cf)
250                    && let Some(db) = LsR::from_buf(bytes, meta.len(), mtime)
251                {
252                    self.dbs.push(db);
253                    break;
254                }
255                match std::fs::read(&file) {
256                    Ok(bytes) => {
257                        let db = LsR::parse(&root, &bytes, meta.len(), mtime);
258                        if let Some(cf) = &cache_file {
259                            write_atomically(cf, db.bytes());
260                        }
261                        self.dbs.push(db);
262                        break;
263                    }
264                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
265                    Err(source) => return Err(Error::Io { path: file, source }),
266                }
267            }
268        }
269        Ok(())
270    }
271
272    /// Returns the expanded value of variable `name`, like
273    /// `kpsewhich -var-value`. Braces are not expanded.
274    pub fn var_value(&self, name: &str) -> Option<String> {
275        let raw = self.raw_var(name)?;
276        Some(self.expand_vars(&raw))
277    }
278
279    /// The environment's value of `name` (`name.progname`, `name_progname`
280    /// or `name`).
281    fn env_var(&self, name: &str) -> Option<String> {
282        let dotted = format!("{name}.{}", self.progname);
283        let underscored = format!("{name}_{}", self.progname);
284        self.env
285            .var(&dotted)
286            .or_else(|| self.env.var(&underscored))
287            .or_else(|| self.env.var(name))
288    }
289
290    /// The value of `name` from texmf.cnf or the built-in values.
291    fn cnf_var(&self, name: &str) -> Option<String> {
292        let value = self
293            .cnf
294            .get(name, &self.progname)
295            .map(str::to_owned)
296            .or_else(|| self.builtins.get(name).cloned())?;
297        Some(self.expand_tilde(&value))
298    }
299
300    /// Looks a variable up without expansion: environment first
301    /// (`NAME.progname`, `NAME_progname`, `NAME`), then the configuration
302    /// files (`NAME.progname`, `NAME`), then built-in variables.
303    fn raw_var(&self, name: &str) -> Option<String> {
304        // kpathsea exports these itself, so they beat texmf.cnf.
305        if matches!(name, "progname" | "engine") {
306            return self.builtins.get(name).cloned();
307        }
308        let dotted = format!("{name}.{}", self.progname);
309        let underscored = format!("{name}_{}", self.progname);
310        let value = self
311            .env
312            .var(&dotted)
313            .or_else(|| self.env.var(&underscored))
314            .or_else(|| self.env.var(name))
315            .or_else(|| self.cnf.get(name, &self.progname).map(str::to_owned))
316            .or_else(|| self.builtins.get(name).cloned())?;
317        // A value starting with `~` refers to the home directory.
318        Some(self.expand_tilde(&value))
319    }
320
321    /// Expands `$VAR` and `${VAR}` references recursively.
322    fn expand_vars(&self, s: &str) -> String {
323        expand::expand_vars(s, &|name| self.raw_var(name), 0)
324    }
325
326    fn expand_tilde(&self, s: &str) -> String {
327        if let Some(rest) = s.strip_prefix('~')
328            && (rest.is_empty() || rest.starts_with('/'))
329        {
330            let home = self.env.var("HOME").unwrap_or_default();
331            return format!("{home}{rest}");
332        }
333        s.to_owned()
334    }
335
336    /// The fully expanded search path for `format`, one element per entry.
337    /// Elements may start with `!!` and contain `//`.
338    pub fn search_path(&mut self, format: FileFormat) -> &[String] {
339        if !self.paths.contains_key(&format) {
340            let info = format.info();
341            // An environment value's extra colon (leading, trailing or
342            // doubled) stands for the value from texmf.cnf (kpathsea
343            // manual, "Default expansion").
344            let mut raw = None;
345            for var in info.env_vars {
346                if let Some(env) = self.env_var(var) {
347                    let below = self
348                        .cnf_var(var)
349                        .unwrap_or_else(|| info.default_path.to_owned());
350                    raw = Some(expand::expand_default(&self.expand_tilde(&env), &below));
351                    break;
352                }
353                if let Some(v) = self.raw_var(var) {
354                    raw = Some(v);
355                    break;
356                }
357            }
358            let raw = raw.unwrap_or_else(|| info.default_path.to_owned());
359            let expanded = self.expand_vars(&raw);
360            let elts: Vec<String> = expand::brace_expand_path(&expanded)
361                .into_iter()
362                .map(|e| match e.strip_prefix("!!") {
363                    Some(rest) => format!("!!{}", self.expand_tilde(rest)),
364                    None => self.expand_tilde(&e),
365                })
366                .filter(|e| !e.is_empty())
367                .collect();
368            self.paths.insert(format, elts);
369        }
370        &self.paths[&format]
371    }
372
373    /// Finds the first file matching `name` in the search path of `format`,
374    /// like `kpsewhich -format=… name`.
375    pub fn find_file(&mut self, name: &str, format: FileFormat) -> Option<PathBuf> {
376        let candidates = candidate_names(name, format);
377        // Absolute and explicitly relative names bypass the search path.
378        if is_absolute_or_explicit_relative(name) {
379            return candidates.iter().map(PathBuf::from).find(|p| p.is_file());
380        }
381        let path = self.search_path(format).to_vec();
382        for elt in &path {
383            if let Some(found) = self.search_element(elt, &candidates) {
384                return Some(found);
385            }
386        }
387        None
388    }
389
390    /// Searches one path element for any of `names`, returning the first
391    /// match.
392    fn search_element(&mut self, elt: &str, names: &[String]) -> Option<PathBuf> {
393        let (db_only, elt) = match elt.strip_prefix("!!") {
394            Some(rest) => (true, rest),
395            None => (false, elt),
396        };
397        // Database lookup when a database covers this element.
398        if let Some(db) = self.dbs.iter().find(|db| db.covers(elt)) {
399            for name in names {
400                if let Some(found) = db.lookup(name, elt) {
401                    return Some(found);
402                }
403            }
404            if db_only {
405                return None;
406            }
407        } else if db_only {
408            return None;
409        }
410        for dir in self.dirs.expand(elt) {
411            for name in names {
412                let candidate = Path::new(&dir).join(name);
413                if candidate.is_file() {
414                    return Some(candidate);
415                }
416            }
417        }
418        None
419    }
420}
421
422/// A stable key for cache file names (FNV-1a).
423fn content_key(b: &[u8]) -> u64 {
424    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
425    for &x in b {
426        h ^= u64::from(x);
427        h = h.wrapping_mul(0x0100_0000_01b3);
428    }
429    h
430}
431
432/// Writes a cache file via a temporary file and a rename; failures are
433/// ignored (the cache is an optimization).
434fn write_atomically(path: &Path, data: &[u8]) {
435    if let Some(dir) = path.parent() {
436        let _ = std::fs::create_dir_all(dir);
437    }
438    let tmp = path.with_extension(format!("tmp{}", std::process::id()));
439    if std::fs::write(&tmp, data).is_ok() && std::fs::rename(&tmp, path).is_err() {
440        let _ = std::fs::remove_file(&tmp);
441    }
442}
443
444/// The names to try for `name`: with each standard suffix first (unless it
445/// already has one), then the name itself.
446fn candidate_names(name: &str, format: FileFormat) -> Vec<String> {
447    let info = format.info();
448    let has_suffix = info.suffixes.iter().any(|s| name.ends_with(s));
449    let mut out = Vec::new();
450    if !has_suffix {
451        for s in info.suffixes {
452            out.push(format!("{name}{s}"));
453        }
454    }
455    out.push(name.to_owned());
456    out
457}
458
459fn is_absolute_or_explicit_relative(name: &str) -> bool {
460    name.starts_with('/') || name.starts_with("./") || name.starts_with("../")
461}
462
463fn parent(p: &str) -> String {
464    let trimmed = p.trim_end_matches('/');
465    if trimmed.is_empty() && p.starts_with('/') {
466        return "/".to_owned();
467    }
468    match trimmed.rfind('/') {
469        Some(0) => "/".to_owned(),
470        Some(i) => trimmed[..i].to_owned(),
471        None => ".".to_owned(),
472    }
473}
474
475/// Locates the directory of `kpsewhich` on `PATH`, resolving symlinks.
476fn locate_kpsewhich_dir() -> Option<PathBuf> {
477    let path = std::env::var_os("PATH")?;
478    for dir in std::env::split_paths(&path) {
479        let candidate = dir.join("kpsewhich");
480        if candidate.is_file() {
481            let real = std::fs::canonicalize(&candidate).unwrap_or(candidate);
482            return real.parent().map(Path::to_path_buf);
483        }
484    }
485    None
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn candidates_add_suffix_first() {
494        assert_eq!(
495            candidate_names("article.cls", FileFormat::Tex),
496            vec!["article.cls.tex", "article.cls"]
497        );
498        assert_eq!(candidate_names("foo.tex", FileFormat::Tex), vec!["foo.tex"]);
499    }
500
501    #[test]
502    fn parent_dirs() {
503        assert_eq!(parent("/usr/bin"), "/usr");
504        assert_eq!(parent("/usr"), "/");
505        assert_eq!(parent("/"), "/");
506    }
507}