retex-engine 1.3.0-alpha.2

The TeX typesetting engine for retex
Documentation
//! expl3's `\keyval_parse:nnn` (and `\keyval_parse:NNn`, the same
//! macro): the key–value parser behind l3keys (`\keys_set:nn`), LaTeX's
//! `\SetKeys` and package options.
//!
//! `\keyval_parse:nnn {<f1>} {<f2>} {<list>}` expands, in two steps
//! (`\__kernel_exp_not:w \tex_expanded:D {{…}}`), to
//! `<f1> {<key>}` for each item without `=` and `<f2> {<key>} {<value>}`
//! for each item with one, protected from further expansion. The
//! native implementation puts back `\__kernel_exp_not:w {<that list>}`,
//! which is what TeX has after the `\tex_expanded:D` step (see the module
//! documentation of [`super`] for why that is exact where the native code
//! runs).
//!
//! The rules (expl3-code.tex 2026-01-19, `l3keys.dtx`):
//! - items are separated by commas of category 12 or 13 at brace depth 0;
//!   items made only of spaces are skipped;
//! - an item is split at its `=` (category 12 or 13, depth 0); more than
//!   one is an error;
//! - keys and values lose all leading and trailing spaces, then one pair of
//!   braces if what remains is a single braced group;
//! - a blank key is an error.
//!
//! The native code declines (the TeX code then runs, with its error
//! messages) for errors, blank keys, items that are only an empty group,
//! space tokens other than character 32, l3keyval's scan marks in the
//! list, and macro parameter tokens.

use std::cell::RefCell;

use super::Native;
use super::deps::Deps;
use crate::Engine;
use crate::cmd::Cmd;
use crate::token::{Token, TokenList};

/// `\keyval_parse:nnn`.
pub(super) const PARSE: Native = Native {
    name: "keyval_parse:nnn",
    fingerprints: &[(22, 0x6edb_e358_624f_a9ee)],
    executes: false,
    run,
};

const NAMES: &[&str] = &[
    "__kernel_exp_not:w",
    "tex_expanded:D",
    "__keyval_loop_active:nnw",
    "__keyval_loop_other:nnw",
    "__keyval_split_other:w",
    "__keyval_split_active:w",
    "__keyval_split_active_auxi:w",
    "__keyval_split_active_auxii:w",
    "__keyval_split_active_auxiii:w",
    "__keyval_split_active_auxiv:w",
    "__keyval_split_active_auxv:w",
    "__keyval_split_other_auxi:w",
    "__keyval_split_other_auxii:w",
    "__keyval_split_other_auxiii:w",
    "__keyval_clean_up_active:w",
    "__keyval_clean_up_other:w",
    "__keyval_end_loop_other:w",
    "__keyval_end_loop_active:w",
    "__keyval_pair:nnnn",
    "__keyval_key:nn",
    "__keyval_if_empty:w",
    "__keyval_if_blank:w",
    "__keyval_if_recursion_tail:w",
    "__keyval_blank_true:w",
    "__keyval_trim:nN",
    "__keyval_trim_auxi:w",
    "__keyval_trim_auxii:w",
    "__keyval_trim_auxiii:w",
    "__keyval_trim_auxiv:w",
    "exp_not:n",
];

const KNOWN: &[u64] = &[0x5e6b_f026_f83c_4c2c];

thread_local! {
    static DEPS: RefCell<Deps> = RefCell::new(Deps::new("keyval", NAMES, KNOWN));
}

fn why(reason: &str) -> bool {
    if super::debug() {
        eprintln!("[native] keyval_parse declines ({reason})");
    }
    false
}

fn is_brace(t: Token, cmd: Cmd) -> bool {
    !t.is_cs() && t.cmd_code() == cmd as u32
}

fn is_space(t: Token) -> bool {
    !t.is_cs() && t.cmd_code() == Cmd::Spacer as u32
}

/// Whether `t` is `c` of category 12, or the active character `c`.
fn is_delim(e: &Engine, t: Token, c: u8) -> bool {
    if t.is_cs() {
        matches!(e.hash.kind(t.cs_id()), crate::hash::CsKind::Active(a) if *a == u32::from(c))
    } else {
        t.cmd_code() == Cmd::OtherChar as u32 && t.chr() == u32::from(c)
    }
}

/// Splits `toks` at depth-0 tokens matching `delim`.
fn split(toks: &[Token], mut delim: impl FnMut(Token) -> bool) -> Vec<&[Token]> {
    let mut out = Vec::new();
    let mut depth = 0i32;
    let mut start = 0;
    for (i, &t) in toks.iter().enumerate() {
        if is_brace(t, Cmd::LeftBrace) {
            depth += 1;
        } else if is_brace(t, Cmd::RightBrace) {
            depth -= 1;
        } else if depth == 0 && delim(t) {
            out.push(&toks[start..i]);
            start = i + 1;
        }
    }
    out.push(&toks[start..]);
    out
}

/// Whether `toks` is one braced group.
fn single_group(toks: &[Token]) -> bool {
    if toks.len() < 2 || !is_brace(toks[0], Cmd::LeftBrace) {
        return false;
    }
    let mut depth = 0;
    for (i, &t) in toks.iter().enumerate() {
        if is_brace(t, Cmd::LeftBrace) {
            depth += 1;
        } else if is_brace(t, Cmd::RightBrace) {
            depth -= 1;
            if depth == 0 {
                return i == toks.len() - 1;
            }
        }
    }
    false
}

/// `\__keyval_trim:nN`: spaces off both ends, then one pair of braces.
fn trim(toks: &[Token]) -> &[Token] {
    let mut a = 0;
    let mut b = toks.len();
    while a < b && is_space(toks[a]) {
        a += 1;
    }
    while b > a && is_space(toks[b - 1]) {
        b -= 1;
    }
    let t = &toks[a..b];
    if single_group(t) {
        &t[1..t.len() - 1]
    } else {
        t
    }
}

/// Whether a key is blank for `\__keyval_if_blank:w`: nothing, or an empty
/// group, as its first undelimited argument.
fn blank(toks: &[Token]) -> bool {
    let mut i = 0;
    while i < toks.len() && is_space(toks[i]) {
        i += 1;
    }
    i == toks.len()
        || (is_brace(toks[i], Cmd::LeftBrace)
            && toks
                .get(i + 1)
                .is_some_and(|&t| is_brace(t, Cmd::RightBrace)))
}

fn run(e: &mut Engine, args: &[TokenList]) -> bool {
    if !DEPS.with(|d| d.borrow_mut().check(e)) {
        return why("dependencies");
    }
    let (f1, f2, list) = (&args[0], &args[1], &args[2]);
    // Tokens that would change the TeX code's matching.
    let marks: Vec<Token> = [
        &b"s__keyval_nil"[..],
        b"s__keyval_mark",
        b"s__keyval_stop",
        b"s__keyval_tail",
    ]
    .iter()
    .filter_map(|n| e.hash.lookup(n).map(Token::cs))
    .collect();
    let bad = |t: &Token| {
        marks.contains(t)
            || (!t.is_cs() && t.cmd_code() == Cmd::CarRet as u32)
            || (is_space(*t) && t.chr() != u32::from(b' '))
    };
    if list.iter().any(bad) || f1.iter().any(bad) || f2.iter().any(bad) {
        return why("tokens");
    }
    let Some(exp_not) = e.hash.lookup(b"__kernel_exp_not:w").map(Token::cs) else {
        return false;
    };
    let mut out = vec![exp_not, Token::LEFT_BRACE];
    for item in split(list, |t| is_delim(e, t, b',')) {
        if item.iter().all(|&t| is_space(t)) {
            continue;
        }
        let parts = split(item, |t| is_delim(e, t, b'='));
        match parts[..] {
            [key] => {
                let k = trim(key);
                if blank(k) {
                    return why("blank key");
                }
                out.extend_from_slice(f1);
                out.push(Token::LEFT_BRACE);
                out.extend_from_slice(k);
                out.push(Token::RIGHT_BRACE);
            }
            [key, value] => {
                let k = trim(key);
                if blank(k) {
                    return why("blank key");
                }
                out.extend_from_slice(f2);
                out.push(Token::LEFT_BRACE);
                out.extend_from_slice(k);
                out.push(Token::RIGHT_BRACE);
                out.push(Token::LEFT_BRACE);
                out.extend_from_slice(trim(value));
                out.push(Token::RIGHT_BRACE);
            }
            _ => return why("misplaced ="),
        }
    }
    out.push(Token::RIGHT_BRACE);
    e.native_insert(out);
    true
}