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    /// Returns the expanded value of variable `name`, like
292    /// `kpsewhich -var-value`. Braces are not expanded.
293    pub fn var_value(&self, name: &str) -> Option<String> {
294        let raw = self.raw_var(name)?;
295        Some(self.expand_vars(&raw))
296    }
297
298    /// The environment's value of `name`. As with kpathsea, an empty value
299    /// counts as unset (`TEXMFHOME= kpsewhich -var-value=TEXMFHOME` prints
300    /// the value from texmf.cnf).
301    fn getenv(&self, name: &str) -> Option<String> {
302        self.env.var(name).filter(|v| !v.is_empty())
303    }
304
305    /// The environment's value of `name` (`name.progname`, `name_progname`
306    /// or `name`).
307    fn env_var(&self, name: &str) -> Option<String> {
308        let dotted = format!("{name}.{}", self.progname);
309        let underscored = format!("{name}_{}", self.progname);
310        self.getenv(&dotted)
311            .or_else(|| self.getenv(&underscored))
312            .or_else(|| self.getenv(name))
313    }
314
315    /// The value of `name` from texmf.cnf or the built-in values.
316    fn cnf_var(&self, name: &str) -> Option<String> {
317        let value = self
318            .cnf
319            .get(name, &self.progname)
320            .map(str::to_owned)
321            .or_else(|| self.builtins.get(name).cloned())?;
322        Some(self.expand_tilde(&value))
323    }
324
325    /// Looks a variable up without expansion: environment first
326    /// (`NAME.progname`, `NAME_progname`, `NAME`), then the configuration
327    /// files (`NAME.progname`, `NAME`), then built-in variables.
328    fn raw_var(&self, name: &str) -> Option<String> {
329        // kpathsea sets these itself, so they beat the environment and
330        // texmf.cnf (observed: `SELFAUTOLOC=/x kpsewhich
331        // -var-value=SELFAUTOLOC` prints kpsewhich's own directory).
332        if matches!(
333            name,
334            "progname"
335                | "engine"
336                | "SELFAUTOLOC"
337                | "SELFAUTODIR"
338                | "SELFAUTOPARENT"
339                | "SELFAUTOGRANDPARENT"
340        ) {
341            return self.builtins.get(name).cloned();
342        }
343        let dotted = format!("{name}.{}", self.progname);
344        let underscored = format!("{name}_{}", self.progname);
345        let value = self
346            .getenv(&dotted)
347            .or_else(|| self.getenv(&underscored))
348            .or_else(|| self.getenv(name))
349            .or_else(|| self.cnf.get(name, &self.progname).map(str::to_owned))
350            .or_else(|| self.builtins.get(name).cloned())?;
351        // A value starting with `~` refers to the home directory.
352        Some(self.expand_tilde(&value))
353    }
354
355    /// Expands `$VAR` and `${VAR}` references recursively.
356    fn expand_vars(&self, s: &str) -> String {
357        expand::expand_vars(s, &|name| self.raw_var(name), 0)
358    }
359
360    fn expand_tilde(&self, s: &str) -> String {
361        if let Some(rest) = s.strip_prefix('~')
362            && (rest.is_empty() || rest.starts_with('/'))
363        {
364            let home = self.getenv("HOME").unwrap_or_default();
365            return format!("{home}{rest}");
366        }
367        s.to_owned()
368    }
369
370    /// The fully expanded search path for `format`, one element per entry.
371    /// Elements may start with `!!` and contain `//`.
372    pub fn search_path(&mut self, format: FileFormat) -> &[String] {
373        if !self.paths.contains_key(&format) {
374            let info = format.info();
375            // An environment value's extra colon (leading, trailing or
376            // doubled) stands for the value from texmf.cnf (kpathsea
377            // manual, "Default expansion").
378            // The environment comes first, for all the format's variables
379            // (`TEXFONTS` for any font format), then texmf.cnf.
380            let mut raw = None;
381            let vars = format.variables(&self.progname);
382            if let Some(env) = vars.iter().find_map(|v| self.env_var(v)) {
383                let below = vars
384                    .iter()
385                    .find_map(|v| self.cnf_var(v))
386                    .unwrap_or_else(|| info.default_path.to_owned());
387                raw = Some(expand::expand_default(&self.expand_tilde(&env), &below));
388            }
389            if raw.is_none() {
390                raw = vars.iter().find_map(|v| self.raw_var(v));
391            }
392            let raw = raw.unwrap_or_else(|| info.default_path.to_owned());
393            let expanded = self.expand_vars(&raw);
394            let elts: Vec<String> = expand::brace_expand_path(&expanded)
395                .into_iter()
396                .map(|e| match e.strip_prefix("!!") {
397                    Some(rest) => format!("!!{}", self.expand_tilde(rest)),
398                    None => self.expand_tilde(&e),
399                })
400                .filter(|e| !e.is_empty())
401                .collect();
402            self.paths.insert(format, elts);
403        }
404        &self.paths[&format]
405    }
406
407    /// Finds the first file matching `name` in the search path of `format`,
408    /// like `kpsewhich -format=… name`.
409    pub fn find_file(&mut self, name: &str, format: FileFormat) -> Option<PathBuf> {
410        let candidates = candidate_names(name, format);
411        // Absolute and explicitly relative names bypass the search path.
412        if is_absolute_or_explicit_relative(name) {
413            let exact = candidates.iter().map(PathBuf::from).find(|p| p.is_file());
414            if exact.is_some() || !self.casefold {
415                return exact;
416            }
417            return candidates.iter().find_map(|c| {
418                let p = Path::new(c);
419                let dir = p.parent().filter(|d| !d.as_os_str().is_empty())?;
420                casefold::find(dir, p.file_name()?.to_str()?)
421            });
422        }
423        let path = self.search_path(format).to_vec();
424        for elt in &path {
425            if let Some(found) = self.search_element(elt, &candidates) {
426                return Some(found);
427            }
428        }
429        None
430    }
431
432    /// Searches one path element for any of `names`, returning the first
433    /// match.
434    fn search_element(&mut self, elt: &str, names: &[String]) -> Option<PathBuf> {
435        let (db_only, elt) = match elt.strip_prefix("!!") {
436            Some(rest) => (true, rest),
437            None => (false, elt),
438        };
439        // Database lookup when a database covers this element.
440        if let Some(db) = self.dbs.iter().find(|db| db.covers(elt)) {
441            for name in names {
442                if let Some(found) = db.lookup(name, elt) {
443                    return Some(found);
444                }
445            }
446            if db_only {
447                return None;
448            }
449        } else if db_only {
450            return None;
451        }
452        let dirs = self.dirs.expand(elt);
453        for dir in &dirs {
454            for name in names {
455                let candidate = Path::new(dir).join(name);
456                if candidate.is_file() {
457                    return Some(candidate);
458                }
459            }
460        }
461        if self.casefold {
462            for dir in &dirs {
463                for name in names {
464                    if let Some(found) = casefold::find(Path::new(dir), name) {
465                        return Some(found);
466                    }
467                }
468            }
469        }
470        None
471    }
472}
473
474/// A stable key for cache file names (FNV-1a).
475fn content_key(b: &[u8]) -> u64 {
476    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
477    for &x in b {
478        h ^= u64::from(x);
479        h = h.wrapping_mul(0x0100_0000_01b3);
480    }
481    h
482}
483
484/// Writes a cache file via a temporary file and a rename; failures are
485/// ignored (the cache is an optimization).
486fn write_atomically(path: &Path, data: &[u8]) {
487    if let Some(dir) = path.parent() {
488        let _ = std::fs::create_dir_all(dir);
489    }
490    let tmp = path.with_extension(format!("tmp{}", std::process::id()));
491    if std::fs::write(&tmp, data).is_ok() && std::fs::rename(&tmp, path).is_err() {
492        let _ = std::fs::remove_file(&tmp);
493    }
494}
495
496/// The names to try for `name`: with each standard suffix first (unless it
497/// already has one), then the name itself.
498fn candidate_names(name: &str, format: FileFormat) -> Vec<String> {
499    let info = format.info();
500    let has_suffix = info
501        .suffixes
502        .iter()
503        .chain(info.alt_suffixes)
504        .any(|s| name.ends_with(s));
505    let mut out = Vec::new();
506    if !has_suffix {
507        for s in info.suffixes {
508            out.push(format!("{name}{s}"));
509        }
510    }
511    if has_suffix || info.bare {
512        out.push(name.to_owned());
513    }
514    out
515}
516
517fn is_absolute_or_explicit_relative(name: &str) -> bool {
518    name.starts_with('/') || name.starts_with("./") || name.starts_with("../")
519}
520
521fn parent(p: &str) -> String {
522    let trimmed = p.trim_end_matches('/');
523    if trimmed.is_empty() && p.starts_with('/') {
524        return "/".to_owned();
525    }
526    match trimmed.rfind('/') {
527        Some(0) => "/".to_owned(),
528        Some(i) => trimmed[..i].to_owned(),
529        None => ".".to_owned(),
530    }
531}
532
533/// Locates the directory of `kpsewhich` on `PATH`, resolving symlinks.
534fn locate_kpsewhich_dir() -> Option<PathBuf> {
535    let path = std::env::var_os("PATH")?;
536    for dir in std::env::split_paths(&path) {
537        let candidate = dir.join("kpsewhich");
538        if candidate.is_file() {
539            let real = std::fs::canonicalize(&candidate).unwrap_or(candidate);
540            return real.parent().map(Path::to_path_buf);
541        }
542    }
543    None
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn candidates_add_suffix_first() {
552        // `.cls` is an alternate suffix of the tex format.
553        assert_eq!(
554            candidate_names("article.cls", FileFormat::Tex),
555            vec!["article.cls"]
556        );
557        assert_eq!(
558            candidate_names("a.foo", FileFormat::Tex),
559            vec!["a.foo.tex", "a.foo"]
560        );
561        assert_eq!(candidate_names("foo.tex", FileFormat::Tex), vec!["foo.tex"]);
562    }
563
564    #[test]
565    fn parent_dirs() {
566        assert_eq!(parent("/usr/bin"), "/usr");
567        assert_eq!(parent("/usr"), "/");
568        assert_eq!(parent("/"), "/");
569    }
570}