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