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