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