retex-lua 1.0.0

Embedded Lua and the LuaTeX Lua API for retex
Documentation
//! The `kpse` library (LuaTeX manual ยง10.7) and the `require` searcher that
//! finds Lua modules through kpathsea.

use mlua::{Lua, MultiValue, Table, Value};
use retex_kpath::{Access, FileFormat};

use crate::with_engine;

/// Maps kpathsea format names to [`FileFormat`].
pub fn format_by_name(s: &str) -> Option<FileFormat> {
    Some(match s {
        "tex" => FileFormat::Tex,
        "other text files" => FileFormat::Text,
        "lua" => FileFormat::Lua,
        "opentype fonts" => FileFormat::OpenType,
        "truetype fonts" => FileFormat::TrueType,
        "type1 fonts" => FileFormat::Type1,
        "tfm" => FileFormat::Tfm,
        "map" => FileFormat::Map,
        "enc files" | "enc" => FileFormat::Enc,
        "vf" => FileFormat::Vf,
        "fmt" => FileFormat::Fmt,
        "bib" => FileFormat::Bib,
        "bst" => FileFormat::Bst,
        "graphic/figure" => FileFormat::Pict,
        _ => return None,
    })
}

fn find(lua: &Lua, name: &str, fmt: FileFormat) -> mlua::Result<Option<String>> {
    with_engine(lua, |e| {
        // Files the job wrote are in the output directory (as for TeX's
        // own file lookup).
        let in_output = e
            .files
            .output_dir
            .as_ref()
            .filter(|_| std::path::Path::new(name).is_relative())
            .map(|d| d.join(name))
            .filter(|p| p.is_file());
        let found = in_output.or_else(|| {
            e.kpse
                .as_ref()
                .and_then(|k| k.borrow_mut().find_file(name, fmt))
        });
        // The pass driver must see job files appear or change.
        e.record_probe(name, found.as_deref());
        found.map(|p| p.to_string_lossy().into_owned())
    })
}

/// `io.open` and `io.lines` check names as LuaTeX's do when it typesets
/// (observed): a name refused by the extended `openout_any` check (modes
/// with `w`, `a` or `+`) or `openin_any` check (reading) is not opened, and
/// `io.lines` then raises an error whose value is nil. `io.open` returns
/// a single nil when it fails, refused or not, without the message and
/// error number of standard Lua.
const CHECKED_IO: &str = r#"
local find, open, lines = string.find, io.open, io.lines
local out_ok, in_ok = kpse.out_name_ok_silent_extended, kpse.in_name_ok_silent_extended
function io.open(name, mode)
  if type(name) == "string" then
    if type(mode) == "string" and find(mode, "[wa+]") then
      if not out_ok(name) then return nil end
    elseif not in_ok(name) then
      return nil
    end
  end
  local f = open(name, mode)
  if f then return f end
  return nil
end
function io.lines(name, ...)
  if type(name) == "string" and not in_ok(name) then error() end
  return lines(name, ...)
end
"#;

/// Makes `io.open` and `io.lines` check file names (see [`CHECKED_IO`]).
pub fn check_io_names(lua: &Lua) -> mlua::Result<()> {
    lua.load(CHECKED_IO).set_name("=[retex io checks]").exec()
}

/// Installs `kpse` and the kpathsea-based `require` searcher.
pub fn install(lua: &Lua) -> mlua::Result<()> {
    let t = lua.create_table()?;
    t.set(
        "find_file",
        lua.create_function(|lua, args: MultiValue| {
            let mut it = args.into_iter();
            let name = match it.next() {
                Some(Value::String(s)) => s.to_str()?.to_owned(),
                _ => return Ok(Value::Nil),
            };
            let fmt = match it.next() {
                Some(Value::String(s)) => format_by_name(&s.to_str()?).unwrap_or(FileFormat::Tex),
                _ => FileFormat::Tex,
            };
            match find(lua, &name, fmt)? {
                Some(p) => Ok(Value::String(lua.create_string(&p)?)),
                None => {
                    with_engine(lua, |e| e.record_absent_file(&name))?;
                    Ok(Value::Nil)
                }
            }
        })?,
    )?;
    t.set(
        "var_value",
        lua.create_function(|lua, name: String| {
            with_engine(lua, |e| {
                e.kpse.as_ref().and_then(|k| k.borrow().var_value(&name))
            })
        })?,
    )?;
    t.set(
        "expand_var",
        lua.create_function(|lua, s: String| {
            let name = s.trim_start_matches('$').to_owned();
            with_engine(lua, |e| {
                e.kpse
                    .as_ref()
                    .and_then(|k| k.borrow().var_value(&name))
                    .unwrap_or_default()
            })
        })?,
    )?;
    t.set(
        "show_path",
        lua.create_function(|lua, fmt: String| {
            let f = format_by_name(&fmt).unwrap_or(FileFormat::Tex);
            with_engine(lua, |e| {
                e.kpse
                    .as_ref()
                    .map(|k| k.borrow_mut().search_path(f).join(":"))
                    .unwrap_or_default()
            })
        })?,
    )?;
    // The name checks of `openin_any` and `openout_any`; the plain ones
    // print kpathsea's warning when they refuse a name.
    for (field, access, extended) in [
        ("in_name_ok", Access::Read, false),
        ("out_name_ok", Access::Write, false),
        ("in_name_ok_silent_extended", Access::Read, true),
        ("out_name_ok_silent_extended", Access::Write, true),
    ] {
        t.set(
            field,
            lua.create_function(move |lua, name: String| {
                with_engine(lua, |e| {
                    if extended {
                        e.kpse
                            .as_ref()
                            .is_none_or(|k| k.borrow().name_ok(&name, access, true).is_ok())
                    } else {
                        e.name_ok(&name, access)
                    }
                })
            })?,
        )?;
    }
    t.set(
        "set_program_name",
        lua.create_function(|_, _a: MultiValue| Ok(()))?,
    )?;
    t.set(
        "version",
        lua.create_function(|_, ()| Ok("kpathsea version 6.4.1"))?,
    )?;
    t.set(
        "new",
        lua.create_function(|lua, _a: MultiValue| lua.globals().get::<Table>("kpse"))?,
    )?;
    lua.globals().set("kpse", t)?;

    // A searcher for `require` that uses kpathsea (inserted after the
    // preload searcher, as LuaTeX does).
    let package: Table = lua.globals().get("package")?;
    let searchers: Table = package.get("searchers")?;
    let searcher = lua.create_function(|lua, name: String| {
        let file = name.replace('.', "/");
        let found = find(lua, &file, FileFormat::Lua)?.or(find(lua, &name, FileFormat::Lua)?);
        match found {
            Some(path) => {
                let mut code = std::fs::read(&path).map_err(mlua::Error::external)?;
                // Like `luaL_loadfile`, ignore a first line starting with
                // `#` (keeping its newline, for line numbers).
                if code.first() == Some(&b'#') {
                    let end = code.iter().position(|&b| b == b'\n').unwrap_or(code.len());
                    code.drain(..end);
                }
                let chunk_name = format!("@{path}");
                let f = lua.load(code).set_name(chunk_name).into_function()?;
                Ok(MultiValue::from_iter([
                    Value::Function(f),
                    Value::String(lua.create_string(&path)?),
                ]))
            }
            None => Ok(MultiValue::from_iter([Value::String(lua.create_string(
                format!("\n\t[kpse lua searcher] file not found: '{name}'"),
            )?)])),
        }
    })?;
    let n = searchers.raw_len();
    // Shift entries 2..n up by one and insert at position 2.
    for i in (2..=n).rev() {
        let v: Value = searchers.raw_get(i)?;
        searchers.raw_set(i + 1, v)?;
    }
    searchers.raw_set(2, searcher)?;
    Ok(())
}