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