retex-kpath 0.9.9

kpathsea-compatible file lookup for retex
Documentation
//! Parsing of `texmf.cnf` files.

use std::collections::HashMap;

/// Variable definitions collected from one or more `texmf.cnf` files.
///
/// The first definition of a variable wins, so files must be parsed in
/// `TEXMFCNF` order.
#[derive(Debug, Default)]
pub struct Cnf {
    /// Keyed by `(name, progname)`; `progname` is empty for plain `NAME`.
    vars: HashMap<(String, String), String>,
}

impl Cnf {
    /// Adds the definitions in `text`, keeping existing ones.
    pub fn parse(&mut self, text: &str) {
        let mut pending = String::new();
        for raw in text.lines() {
            let line = raw.trim_end();
            // A trailing backslash continues the logical line.
            if let Some(stripped) = line.strip_suffix('\\') {
                pending.push_str(stripped);
                continue;
            }
            pending.push_str(line);
            let logical = std::mem::take(&mut pending);
            self.parse_line(&logical);
        }
        if !pending.is_empty() {
            let logical = std::mem::take(&mut pending);
            self.parse_line(&logical);
        }
    }

    fn parse_line(&mut self, line: &str) {
        let line = strip_comment(line).trim();
        if line.is_empty() {
            return;
        }
        let name_end = line
            .find(|c: char| c.is_whitespace() || c == '=' || c == '.')
            .unwrap_or(line.len());
        let name = &line[..name_end];
        let mut rest = &line[name_end..];
        let mut progname = "";
        if let Some(r) = rest.strip_prefix('.') {
            let end = r
                .find(|c: char| c.is_whitespace() || c == '=')
                .unwrap_or(r.len());
            progname = &r[..end];
            rest = &r[end..];
        }
        let rest = rest.trim_start();
        let value = rest.strip_prefix('=').unwrap_or(rest).trim();
        if name.is_empty() {
            return;
        }
        // On Unix, `;` in values is a path separator, equivalent to `:`.
        let value = value.replace(';', ":");
        self.vars
            .entry((name.to_owned(), progname.to_owned()))
            .or_insert(value);
    }

    /// Looks up `name`, preferring the `name.progname` definition.
    pub fn get(&self, name: &str, progname: &str) -> Option<&str> {
        self.vars
            .get(&(name.to_owned(), progname.to_owned()))
            .or_else(|| self.vars.get(&(name.to_owned(), String::new())))
            .map(String::as_str)
    }
}

/// Removes a comment: `%` or `#` at the start of the line or after
/// whitespace, through the end of the line.
fn strip_comment(line: &str) -> &str {
    let bytes = line.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if (b == b'%' || b == b'#') && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
            return &line[..i];
        }
    }
    line
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn first_definition_wins_and_progname_preferred() {
        let mut cnf = Cnf::default();
        cnf.parse("A = 1\nA = 2 % comment\nB.lualatex = x;y\nB = z\n");
        cnf.parse("A = 3\n");
        assert_eq!(cnf.get("A", "lualatex"), Some("1"));
        assert_eq!(cnf.get("B", "lualatex"), Some("x:y"));
        assert_eq!(cnf.get("B", "tex"), Some("z"));
    }

    #[test]
    fn continuation_and_optional_equals() {
        let mut cnf = Cnf::default();
        cnf.parse("LONG = a,\\\n  b\nNOEQ value\n# full comment\n");
        assert_eq!(cnf.get("LONG", ""), Some("a,  b"));
        assert_eq!(cnf.get("NOEQ", ""), Some("value"));
    }
}