\useOpTeX
\load[doc]

\tit The `luakeyval` Module
\hfill Version: 0.1, 2025-12-01  \par
\centerline{\it Udi Fogiel, 2025}

\parindent0pt\parskip5pt\parfillskip=20ptplus1fill
\overfullrule=0pt

The luakeyval LuTeX module is a minimal key/value parser for LuaTeX formats
based on `token.scan_key_cs`. As such, the keyword parsing is similar to how \TeX/
parses keywords in special primitives like `\hrule`. 

Unlike \TeX's scanner, `luakeyval` scans key/val pairs inside braces.
This avoids the need to append a `\relax` to the key/val
list, which would otherwise be required to stop \TeX/ from scanning
too far and potentially creating expansion problems.


\sec Usage
The module provides the `process` function, which accepts
a table of keys, and a table of error messages.

Each key in the table should have a table of parameters as a value. The possible
parameters are
\def\param #1:{{\bf #1:}\hskip1em\ignorespaces}
\begitems \style O
* \param scanner: a function that will scan the value of the key from \TeX/ to Lua.
                  The value will usually be a function from \LuaTeX's token library,
                  but you can use your own.
* \param    args: a table of arguments that will be passed to the scanner function. 
* \param default: default: a value returned if the key is not followed by a `=`.
* \param    func: a function that will be executed each time the key appears.
\enditems
None of the parameters is mandatory, but a key must have at least
one of `default` or `scanner`.


The error messages table can have the following entries
\begitems \style O
* \param         error1: a message that will be displayed if something went wrong
                         while processing a key/val list. 
* \param         error2: a message that will be displayed after `error1` in case a user
                         press the `H` key for more information.
* \param value_required: a message that will be displayed if no value given to a key
                         (when there is no `=` after the key) and the key does not have
                         a default value.
* \param value_forbidden: a message that will be displayed if a key was given a value
                         and the key does not have a `scanner` function.
\enditems

\sec Example
The following code defines the `\coloredrule` macro,
which accepts the keys `width`, `height`, `depth` and `color`,
and prints a colored rule according to the values given.

\begtt \hisyntax{lua}
local keyval = require('luakeyval')
local rule_keys = {
    width = {scanner = token.scan_dimen, default = tex.sp('1cm')},
    height = {scanner = token.scan_dimen, default = tex.sp('1ex')},
    depth = {scanner = token.scan_dimen, default = 0},
    color = {scanner = token.scan_string},
}

local messages = {
    error1 = "colored rule: Wrong syntax in \\coloredrule",
    value_forbidden = 'colored rule: The key "%s" does not accept a value',
    value_required = 'colored rule: The key "%s" requires a value',
}

local function make_rule()
    local opts = keyval.process(rule_keys, messages)
    local rule = node.new('rule')
    rule.width = opts.width or tex.sp('1cm')
    rule.height = opts.height or tex.sp('1ex')
    rule.depth = opts.depth or 0
    local color_start = node.new('whatsit', 'pdf_literal')
    color_start.mode = 0
    color_start.data = opts.color .. " rg"
    local color_end = node.new('whatsit', 'pdf_literal')
    color_end.mode = 0
    color_end.data = '0 g'
    rule.next = color_end
    color_start.next = rule
    rule = color_start
    node.write(rule)
end

token.set_lua('coloredrule', #lua.get_functions_table() +1, 'protected')
lua.get_functions_table()[#lua.get_functions_table()+1] = make_rule
\endtt

\beglua
local keyval = require('luakeyval')
local rule_keys = {
    width = {scanner = token.scan_dimen, default = tex.sp('1cm')},
    height = {scanner = token.scan_dimen, default = tex.sp('1ex')},
    depth = {scanner = token.scan_dimen, default = 0},
    color = {scanner = token.scan_string},
}

local messages = {
    error1 = "colored rule: Wrong syntax in \\coloredrule",
    value_forbidden = 'colored rule: The key "%s" does not accept a value',
    value_required = 'colored rule: The key "%s" requires a value',
}

local function make_rule()
    local opts = keyval.process(rule_keys, messages)
    local width = opts.width or tex.sp('1cm')
    local height = opts.height or tex.sp('1ex')
    local depth = opts.depth or 0
    local rule = node.new('rule')
    rule.width = width
    rule.height = height
    rule.depth = depth
    local color_start = node.new('whatsit', 'pdf_literal')
    color_start.mode = 0
    color_start.data = opts.color .. " rg"
    local color_end = node.new('whatsit', 'pdf_literal')
    color_end.mode = 0
    color_end.data = '0 g'
    rule.next = color_end
    color_start.next = rule
    rule = color_start
    node.write(rule)
end

token.set_lua('coloredrule', #lua.get_functions_table() +1, 'protected')
lua.get_functions_table()[#lua.get_functions_table()+1] = make_rule
\endlua

Now `\coloredrule{width = 10pt height = 5pt color={1 0 0}}` prints
\coloredrule{width = 10pt height = 5pt color={1 0 0}}

\sec Important Limitations
Since the key/val parser is only a minimal layer
on top of `token.scan_keyword`, key/val pairs should be separated with spaces,
not commas, and avoid defining a key that is a prefix of another key
(unless you control the scanning order, see \ref[order]{Section @}).

\label[order]
\sec Scanning Order
The `process` function loops over the keys table using LuaTeX's
`token.scan_key_cs`. Since Lua tables do not guarantee key order when
iterating with `pairs()`, the scanning order is not deterministic
if you just provide a plain table.

This is important when one key is a prefix of another (for example,
`long` and `longer`). If the shorter key is checked first,
it may match part of the input and leave extra characters unprocessed,
leading to errors.

To control the scanning order explicitly, pass a third argument to
`process`: a list of keys in the exact order they should be scanned.
This also allows scanning only a subset of keys if desired.

\begtt \hisyntax{lua}
local keys = {
  long  = {scanner = token.scan_string},
  longer = {scanner = token.scan_string},
}

-- Default scan (order not guaranteed)
local opts = keyval.process(keys, messages)
-- input: {longer="test"} may incorrectly match 'long' first

-- Controlled scan with explicit order
local order = {"longer", "long"}
local opts = keyval.process(keys, messages, order)
\endtt

\sec Implementation
The full Lua implementation is shown below for reference.
\verbinput \vitt{luakeyval.lua} \hisyntax{lua} (1-) luakeyval.lua

\bye

