diff options
author | Norbert Preining <norbert@preining.info> | 2022-06-10 03:02:43 +0000 |
---|---|---|
committer | Norbert Preining <norbert@preining.info> | 2022-06-10 03:02:43 +0000 |
commit | fb4909f4112d8b778570cf1c2e25ca18c63bc1ca (patch) | |
tree | 92ffbc5b652338ec52811278f6f700718b359c93 /macros/luatex/generic/luakeys/luakeys.lua | |
parent | 16559ce13a74c7a182875559b379cac00b07efda (diff) |
CTAN sync 202206100302
Diffstat (limited to 'macros/luatex/generic/luakeys/luakeys.lua')
-rw-r--r-- | macros/luatex/generic/luakeys/luakeys.lua | 1269 |
1 files changed, 862 insertions, 407 deletions
diff --git a/macros/luatex/generic/luakeys/luakeys.lua b/macros/luatex/generic/luakeys/luakeys.lua index c5ccf6b01d..ae689d02c1 100644 --- a/macros/luatex/generic/luakeys/luakeys.lua +++ b/macros/luatex/generic/luakeys/luakeys.lua @@ -15,59 +15,120 @@ -- -- This work consists of the files luakeys.lua, luakeys.sty, luakeys.tex -- luakeys-debug.sty and luakeys-debug.tex. - --- A key-value parser written with Lpeg. -- --- Explanations of some LPeg notation forms: --- --- * `patt ^ 0` = `expression *` --- * `patt ^ 1` = `expression +` --- * `patt ^ -1` = `expression ?` --- * `patt1 * patt2` = `expression1 expression2`: Sequence --- * `patt1 + patt2` = `expression1 / expression2`: Ordered choice --- --- * [TUGboat article: Parsing complex data formats in LuaTEX with LPEG](https://tug.org/TUGboat/tb40-2/tb125menke-Patterndf) --- -- @module luakeys - local lpeg = require('lpeg') -local Variable = lpeg.V -local Pattern = lpeg.P -local Set = lpeg.S -local Range = lpeg.R -local CaptureGroup = lpeg.Cg -local CaptureFolding = lpeg.Cf -local CaptureTable = lpeg.Ct -local CaptureConstant = lpeg.Cc -local CaptureSimple = lpeg.C if not tex then - tex = {} + tex = { + -- Dummy function for the tests. + sp = function(input) + return 1234567 + end, + } +end + +if not token then + token = { + set_macro = function(csname, content, global) + end, + } +end + +local utils = { + --- Get the size of an array like table `{ 'one', 'two', 'three' }` = 3. + -- + -- @tparam table value A table or any input. + -- + -- @treturn number The size of the array like table. 0 if the input is + -- no table or the table is empty. + get_array_size = function(value) + local count = 0 + if type(value) == 'table' then + for _ in ipairs(value) do + count = count + 1 + end + end + return count + end, + + --- Get the size of a table `{ one = 'one', 'two', 'three' }` = 3. + -- + -- @tparam table value A table or any input. + -- + -- @treturn number The size of the array like table. 0 if the input is + -- no table or the table is empty. + get_table_size = function(value) + local count = 0 + if type(value) == 'table' then + for _ in pairs(value) do + count = count + 1 + end + end + return count + end, + + remove_from_array = function(array, element) + for index, value in pairs(array) do + if element == value then + array[index] = nil + return value + end + end + end, +} - -- Dummy function for the tests. - tex['sp'] = function (input) - return 1234567 +--- https://stackoverflow.com/a/1283608/10193818 +local function merge_tables(target, t2) + for k, v in pairs(t2) do + if type(v) == 'table' then + if type(target[k] or false) == 'table' then + merge_tables(target[k] or {}, t2[k] or {}) + elseif target[k] == nil then + target[k] = v + end + elseif target[k] == nil then + target[k] = v + end end + return target end ---- Option handling --- @section +--- http://lua-users.org/wiki/CopyTable +local function clone_table(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in next, orig, nil do + copy[clone_table(orig_key)] = clone_table(orig_value) + end + setmetatable(copy, clone_table(getmetatable(orig))) + else -- number, string, boolean, etc + copy = orig + end + return copy +end --- This table stores all allowed option keys. -local option_keys = { - 'convert_dimensions', - 'unpack_single_array_values', - 'standalone_as_true', - 'converter', - 'case_insensitive_keys' +local all_options = { + convert_dimensions = false, + debug = false, + default = true, + defaults = false, + defs = false, + format_keys = false, + hooks = {}, + naked_as_value = false, + no_error = false, + postprocess = false, + preprocess = false, + unpack = true, } --- The default options. -local default_options = { - convert_dimensions = true, - unpack_single_array_values = true, - standalone_as_true = false, -} +local default_options = clone_table(all_options) local function throw_error(message) if type(tex.error) == 'function' then @@ -77,58 +138,142 @@ local function throw_error(message) end end ---- Convert a key so that it can be written as a table field without --- quotes and square brackets (for example `one 2` becomes `one_2`). --- The key can then reference values from a table using dot notation. --- (`table["one 2"]` becomes `table.one_2`). --- --- @tparam string key The key to be converted. --- --- @treturn string The converted key. -local function luafy_key(key) - return key:gsub('[^%w]+', '_') -end +local l3_code_cctab = 10 ---- Convert all keys in a table to strings containig only alphanumeric --- characters and underscores. +--- Convert back to strings +-- @section + +--- The function `render(tbl)` reverses the function +-- `parse(kv_string)`. It takes a Lua table and converts this table +-- into a key-value string. The resulting string usually has a +-- different order as the input table. In Lua only tables with +-- 1-based consecutive integer keys (a.k.a. array tables) can be +-- parsed in order. -- --- @param raw_options Some raw options. +-- @tparam table result A table to be converted into a key-value string. -- --- @treturn table Returns always a table. If the input value is not a --- an empty table is returned. -local function luafy_options(raw_options) - if type(raw_options) ~= 'table' then - raw_options = {} - end - local options = {} - for key, value in pairs(raw_options) do - options[luafy_key(key)] = value +-- @treturn string A key-value string that can be passed to a TeX +-- macro. +local function render(result) + local function render_inner(result) + local output = {} + local function add(text) + table.insert(output, text) + end + for key, value in pairs(result) do + if (key and type(key) == 'string') then + if (type(value) == 'table') then + if (next(value)) then + add(key .. '={') + add(render_inner(value)) + add('},') + else + add(key .. '={},') + end + else + add(key .. '=' .. tostring(value) .. ',') + end + else + add(tostring(value) .. ',') + end + end + return table.concat(output) end - return options + return render_inner(result) end ---- All option keys can be written with underscores or with spaces as --- separators. --- For the LaTeX version of the macro --- `\luakeysdebug[options]{kv-string}`. +--- The function `stringify(tbl, for_tex)` converts a Lua table into a +-- printable string. Stringify a table means to convert the table into +-- a string. This function is used to realize the `debug` function. +-- `stringify(tbl, true)` (`for_tex = true`) generates a string which +-- can be embeded into TeX documents. The macro `\luakeysdebug{}` uses +-- this option. `stringify(tbl, false)` or `stringify(tbl)` generate a +-- string suitable for the terminal. -- --- @tparam table options_raw Options in a raw format. The table may be --- empty or some keys are not set. +-- @tparam table result A table to stringify. -- --- @treturn table -local function normalize_parse_options (options_raw) - options_raw = luafy_options(options_raw) - local options = {} - - for _, option_name in ipairs(option_keys) do - if options_raw[option_name] ~= nil then - options[option_name] = options_raw[option_name] - else - options[option_name] = default_options[option_name] +-- @tparam boolean for_tex Stringify the table into a text string that +-- can be embeded inside a TeX document via tex.print(). Curly braces +-- and whites spaces are escaped. +-- +-- https://stackoverflow.com/a/54593224/10193818 +local function stringify(result, for_tex) + local line_break, start_bracket, end_bracket, indent + + if for_tex then + line_break = '\\par' + start_bracket = '$\\{$' + end_bracket = '$\\}$' + indent = '\\ \\ ' + else + line_break = '\n' + start_bracket = '{' + end_bracket = '}' + indent = ' ' + end + + local function stringify_inner(input, depth) + local output = {} + depth = depth or 0 + + local function add(depth, text) + table.insert(output, string.rep(indent, depth) .. text) + end + + local function format_key(key) + if (type(key) == 'number') then + return string.format('[%s]', key) + else + return string.format('[\'%s\']', key) + end end + + if type(input) ~= 'table' then + return tostring(input) + end + + for key, value in pairs(input) do + if (key and type(key) == 'number' or type(key) == 'string') then + key = format_key(key) + + if (type(value) == 'table') then + if (next(value)) then + add(depth, key .. ' = ' .. start_bracket) + add(0, stringify_inner(value, depth + 1)) + add(depth, end_bracket .. ','); + else + add(depth, key .. ' = ' .. start_bracket .. end_bracket .. ',') + end + else + if (type(value) == 'string') then + value = string.format('\'%s\'', value) + else + value = tostring(value) + end + + add(depth, key .. ' = ' .. value .. ',') + end + end + end + + return table.concat(output, line_break) end - return options + return + start_bracket .. line_break .. stringify_inner(result, 1) .. line_break .. + end_bracket +end + +--- The function `debug(tbl)` pretty prints a Lua table to standard +-- output (stdout). It is a utility function that can be used to +-- debug and inspect the resulting Lua table of the function +-- `parse`. You have to compile your TeX document in a console to +-- see the terminal output. +-- +-- @tparam table result A table to be printed to standard output for +-- debugging purposes. +local function debug(result) + print('\n' .. stringify(result, false)) end --- Parser / Lpeg related @@ -136,18 +281,42 @@ end --- Generate the PEG parser using Lpeg. -- +-- Explanations of some LPeg notation forms: +-- +-- * `patt ^ 0` = `expression *` +-- * `patt ^ 1` = `expression +` +-- * `patt ^ -1` = `expression ?` +-- * `patt1 * patt2` = `expression1 expression2`: Sequence +-- * `patt1 + patt2` = `expression1 / expression2`: Ordered choice +-- +-- * [TUGboat article: Parsing complex data formats in LuaTEX with LPEG](https://tug.org/TUGboat/tb40-2/tb125menke-Patterndf) +-- -- @treturn userdata The parser. -local function generate_parser(options) +local function generate_parser(initial_rule, convert_dimensions) + if convert_dimensions == nil then + convert_dimensions = false + end + + local Variable = lpeg.V + local Pattern = lpeg.P + local Set = lpeg.S + local Range = lpeg.R + local CaptureGroup = lpeg.Cg + local CaptureFolding = lpeg.Cf + local CaptureTable = lpeg.Ct + local CaptureConstant = lpeg.Cc + local CaptureSimple = lpeg.C + -- Optional whitespace local white_space = Set(' \t\n\r') --- Match literal string surrounded by whitespace local ws = function(match) - return white_space^0 * Pattern(match) * white_space^0 + return white_space ^ 0 * Pattern(match) * white_space ^ 0 end - local capture_dimension = function (input) - if options.convert_dimensions then + local capture_dimension = function(input) + if convert_dimensions then return tex.sp(input) else return input @@ -180,8 +349,9 @@ local function generate_parser(options) end end + -- LuaFormatter off return Pattern({ - 'list', + [1] = initial_rule, -- list_item* list = CaptureFolding( @@ -220,6 +390,9 @@ local function generate_parser(options) Variable('string_quoted') * -Variable('value') + Variable('string_unquoted'), + -- for is.boolean() + boolean_only = Variable('boolean') * -1, + -- boolean_true / boolean_false boolean = ( @@ -237,18 +410,31 @@ local function generate_parser(options) Pattern('FALSE') + Pattern('False'), + -- for is.dimension() + dimension_only = Variable('dimension') * -1, + dimension = ( - Variable('sign')^0 * white_space^0 * Variable('tex_number') * white_space^0 * Variable('unit') ) / capture_dimension, - number = - (white_space^0 * (Variable('lua_number') / tonumber) * white_space^0) , + -- for is.number() + number_only = Variable('number') * -1, + + -- capture number + number = Variable('tex_number') / tonumber, + -- sign? white_space? (integer+ fractional? / fractional) tex_number = - (Variable('integer')^1 * (Pattern('.') * Variable('integer')^1)^0) + - (Pattern('.') * Variable('integer')^1), + Variable('sign')^0 * white_space^0 * + (Variable('integer')^1 * Variable('fractional')^0) + + Variable('fractional'), + + sign = Set('-+'), + + fractional = Pattern('.') * Variable('integer')^1, + + integer = Range('09')^1, -- 'bp' / 'BP' / 'cc' / etc. -- https://raw.githubusercontent.com/latex3/lualibs/master/lualibs-util-dim.lua @@ -267,18 +453,6 @@ local function generate_parser(options) Pattern('pt') + Pattern('PT') + Pattern('sp') + Pattern('SP'), - lua_number = - Variable('int') * - Variable('frac')^-1, - - int = Variable('sign')^-1 * ( - Range('19') * Variable('integer') + Variable('integer') - ), - - frac = Pattern('.') * Variable('integer'), - sign = Set('-+'), - integer = Range('09')^1, - -- '"' ('\"' / !'"')* '"' string_quoted = white_space^0 * Pattern('"') * @@ -294,68 +468,25 @@ local function generate_parser(options) word_unquoted = (1 - white_space - Set('{},='))^1 }) +-- LuaFormatter on end ---- Get the size of an array like table `{ 'one', 'two', 'three' }` = 3. --- --- @tparam table value A table or any input. --- --- @treturn number The size of the array like table. 0 if the input is --- no table or the table is empty. -local function get_array_size(value) - local count = 0 - if type(value) == 'table' then - for _ in ipairs(value) do count = count + 1 end - end - return count -end - ---- Get the size of a table `{ one = 'one', 'two', 'three' }` = 3. --- --- @tparam table value A table or any input. --- --- @treturn number The size of the array like table. 0 if the input is --- no table or the table is empty. -local function get_table_size(value) - local count = 0 - if type(value) == 'table' then - for _ in pairs(value) do count = count + 1 end - end - return count -end - ---- Unpack a single valued array table like `{ 'one' }` into `one` or --- `{ 1 }` into `1`. --- --- @treturn If the value is a array like table with one non table typed --- value in it, the unpacked value, else the unchanged input. -local function unpack_single_valued_array_table(value, options) - if - type(value) == 'table' and - get_array_size(value) == 1 and - get_table_size(value) == 1 and - type(value[1]) ~= 'table' - then - if type(value[1]) == 'string' and options.standalone_as_true then - return value - else - return value[1] - end - end - return value -end - -local function visit_parse_tree(parse_tree, callback_func) - if type(parse_tree) ~= 'table' then - throw_error('Parse tree has to be a table') +local function visit_tree(tree, callback_func) + if type(tree) ~= 'table' then + throw_error('Parameter “tree” has to be a table, got: ' .. + tostring(tree)) end - local function visit_parse_tree_recursive(root_table, current_table, result, depth, callback_func) - for key, value in pairs(current_table) do + local function visit_tree_recursive(tree, + current, + result, + depth, + callback_func) + for key, value in pairs(current) do if type(value) == 'table' then - value = visit_parse_tree_recursive(root_table, value, {}, depth + 1, callback_func) + value = visit_tree_recursive(tree, value, {}, depth + 1, callback_func) end - key, value = callback_func(key, value, depth, current_table, root_table) + key, value = callback_func(key, value, depth, current, tree) if key ~= nil and value ~= nil then result[key] = value @@ -366,255 +497,574 @@ local function visit_parse_tree(parse_tree, callback_func) end end - return visit_parse_tree_recursive(parse_tree, parse_tree, {}, 1, callback_func) + local result = visit_tree_recursive(tree, tree, {}, 1, callback_func) + + if result == nil then + return {} + end + return result end ---- Normalize the result tables of the LPeg parser. This normalization --- tasks are performed on the raw input table coming directly from the --- PEG parser: --- --- * Unpack all single valued array like tables: `{ 'text' }` into --- `text` --- --- @tparam table raw The raw input table coming directly from the PEG --- parser --- --- @tparam table options Some options. A table with the key --- `unpack_single_array_values` --- --- @treturn table A normalized table ready for the outside world. -local function normalize(raw, options) - local function normalize_recursive(raw, result, options) - for key, value in pairs(raw) do - if options.unpack_single_array_values then - value = unpack_single_valued_array_table(value, options) - end - if type(value) == 'table' then - result[key] = normalize_recursive(value, {}, options) - else - result[key] = value - end +local is = { + boolean = function(value) + if value == nil then + return false end - return result - end - raw = normalize_recursive(raw, {}, options) + if type(value) == 'boolean' then + return true + end + local parser = generate_parser('boolean_only', false) + local result = parser:match(value) + return result ~= nil + end, + + dimension = function(value) + if value == nil then + return false + end + local parser = generate_parser('dimension_only', false) + local result = parser:match(value) + return result ~= nil + end, + + integer = function(value) + local n = tonumber(value) + if n == nil then + return false + end + return n == math.floor(n) + end, - if options.standalone_as_true then - raw = visit_parse_tree(raw, function (key, value) - if type(key) == 'number' and type(value) == 'string' then - return value, true - end - return key, value - end) - end + number = function(value) + if value == nil then + return false + end + if type(value) == 'number' then + return true + end + local parser = generate_parser('number_only', false) + local result = parser:match(value) + return result ~= nil + end, + + string = function(value) + return type(value) == 'string' + end, +} - if options.case_insensitive_keys then - raw = visit_parse_tree(raw, function (key, value) - if type(key) == 'string' then - return key:lower(), value - end - return key, value - end) +--- Apply the key-value-pair definitions (defs) on an input table in a +--- recursive fashion. +--- +---@param defs table A table containing all definitions. +---@param opts table The parse options table. +---@param input table The current input table. +---@param output table The current output table. +---@param unknown table Always the root unknown table. +---@param key_path table An array of key names leading to the current +---@param input_root table The root input table +--- input and output table. +local function apply_definitions(defs, + opts, + input, + output, + unknown, + key_path, + input_root) + local exclusive_groups = {} + + local function add_to_key_path(key_path, key) + local new_key_path = {} + + for index, value in ipairs(key_path) do + new_key_path[index] = value + end + + table.insert(new_key_path, key) + return new_key_path end - return raw -end + local function set_default_value(def) + if def.default ~= nil then + return def.default + elseif opts ~= nil and opts.default ~= nil then + return opts.default + end + return true + end ---- Parse a LaTeX/TeX style key-value string into a Lua table. With --- this function you should be able to parse key-value strings like --- this example: --- --- show, --- hide, --- key with spaces = String without quotes, --- string="String with double quotes: ,{}=", --- dimension = 1cm, --- number = -1.2, --- list = {one,two,three}, --- key value list = {one=one,two=two,three=three}, --- nested key = { --- nested key 2= { --- key = value, --- }, --- }, --- --- The string above results in this Lua table: --- --- { --- 'show', --- 'hide', --- ['key with spaces'] = 'String without quotes', --- string = 'String with double quotes: ,{}=', --- dimension = 1864679, --- number = -1.2, --- list = {'one', 'two', 'three'}, --- key value list = { --- one = 'one', --- three = 'three', --- two = 'two' --- }, --- ['nested key'] = { --- ['nested key 2'] = { --- key = 'value' --- } --- }, --- } --- --- @tparam string kv_string A string in the TeX/LaTeX style key-value --- format as described above. --- --- @tparam table options A table containing --- settings: `convert_dimensions`, `unpack_single_array_values`, `standalone_as_true`, `converter` --- --- @treturn table A hopefully properly parsed table you can do --- something useful with. -local function parse (kv_string, options) - if kv_string == nil then - return {} + local function find_value(search_key, def) + if input[search_key] ~= nil then + local value = input[search_key] + input[search_key] = nil + return value + -- naked keys: values with integer keys + elseif utils.remove_from_array(input, search_key) ~= nil then + return set_default_value(def) + end end - options = normalize_parse_options(options) - local parser = generate_parser(options) - local parse_tree = parser:match(kv_string) + local apply = { + alias = function(value, key, def) + if type(def.alias) == 'string' then + def.alias = { def.alias } + end + local alias_value + local used_alias_key + -- To get an error if the key and an alias is present + if value ~= nil then + alias_value = value + used_alias_key = key + end + for _, alias in ipairs(def.alias) do + local v = find_value(alias, def) + if v ~= nil then + if alias_value ~= nil then + throw_error(string.format( + 'Duplicate aliases “%s” and “%s” for key “%s”!', + used_alias_key, alias, key)) + end + used_alias_key = alias + alias_value = v + end + end + if alias_value ~= nil then + return alias_value + end + end, - if options.converter ~= nil and type(options.converter) == 'function' then - parse_tree = visit_parse_tree(parse_tree, options.converter) - end - return normalize(parse_tree, options) -end + always_present = function(value, key, def) + if value == nil and def.always_present then + return set_default_value(def) + end + end, ---- Convert back to strings --- @section + choices = function(value, key, def) + if value == nil then + return + end + if def.choices ~= nil and type(def.choices) == 'table' then + local is_in_choices = false + for _, choice in ipairs(def.choices) do + if value == choice then + is_in_choices = true + end + end + if not is_in_choices then + throw_error('The value “' .. value .. + '” does not exist in the choices: ' .. + table.concat(def.choices, ', ') .. '!') + end + end + end, ---- The function `render(tbl)` reverses the function --- `parse(kv_string)`. It takes a Lua table and converts this table --- into a key-value string. The resulting string usually has a --- different order as the input table. In Lua only tables with --- 1-based consecutive integer keys (a.k.a. array tables) can be --- parsed in order. --- --- @tparam table tbl A table to be converted into a key-value string. --- --- @treturn string A key-value string that can be passed to a TeX --- macro. -local function render (tbl) - local function render_inner(tbl) - local output = {} - local function add(text) - table.insert(output, text) - end - for key, value in pairs(tbl) do - if (key and type(key) == 'string') then - if (type(value) == 'table') then - if (next(value)) then - add(key .. '={') - add(render_inner(value)) - add('},') + data_type = function(value, key, def) + if value == nil then + return + end + if def.data_type ~= nil then + local converted + -- boolean + if def.data_type == 'boolean' then + if value == 0 or value == '' or not value then + converted = false else - add(key .. '={},') + converted = true end + -- dimension + elseif def.data_type == 'dimension' then + if is.dimension(value) then + converted = value + end + -- integer + elseif def.data_type == 'integer' then + if is.number(value) then + converted = math.floor(tonumber(value)) + end + -- number + elseif def.data_type == 'number' then + if is.number(value) then + converted = tonumber(value) + end + -- string + elseif def.data_type == 'string' then + converted = tostring(value) else - add(key .. '=' .. tostring(value) .. ',') + throw_error('Unknown data type: ' .. def.data_type) end - else - add(tostring(value) .. ',') + if converted == nil then + throw_error('The value “' .. value .. '” of the key “' .. key .. + '” could not be converted into the data type “' .. + def.data_type .. '”!') + else + return converted + end + end + end, + + exclusive_group = function(value, key, def) + if value == nil then + return + end + if def.exclusive_group ~= nil then + if exclusive_groups[def.exclusive_group] ~= nil then + throw_error('The key “' .. key .. + '” belongs to a mutually exclusive group “' .. + def.exclusive_group .. '” and the key “' .. + exclusive_groups[def.exclusive_group] .. + '” is already present!') + else + exclusive_groups[def.exclusive_group] = key + end + end + end, + + l3_tl_set = function(value, key, def) + if value == nil then + return + end + if def.l3_tl_set ~= nil then + tex.print(l3_code_cctab, '\\tl_set:Nn \\g_' .. def.l3_tl_set .. '_tl') + tex.print('{' .. value .. '}') + end + end, + + macro = function(value, key, def) + if value == nil then + return + end + if def.macro ~= nil then + token.set_macro(def.macro, value, 'global') + end + end, + + match = function(value, key, def) + if value == nil then + return end + if def.match ~= nil then + if type(def.match) ~= 'string' then + throw_error('def.match has to be a string') + end + local match = string.match(value, def.match) + if match == nil then + throw_error('The value “' .. value .. '” of the key “' .. key .. + '” does not match “' .. def.match .. '”!') + else + return match + end + end + end, + + opposite_keys = function(value, key, def) + if def.opposite_keys ~= nil then + local true_value = def.opposite_keys[true] + local false_value = def.opposite_keys[false] + if true_value == nil or false_value == nil then + throw_error( + 'Usage opposite_keys = { [true] = "...", [false] = "..." }') + end + if utils.remove_from_array(input, true_value) ~= nil then + return true + elseif utils.remove_from_array(input, false_value) ~= nil then + return false + end + end + end, + + process = function(value, key, def) + if value == nil then + return + end + if def.process ~= nil and type(def.process) == 'function' then + return def.process(value, input_root, output, unknown) + end + end, + + required = function(value, key, def) + if def.required ~= nil and def.required and value == nil then + throw_error(string.format('Missing required key “%s”!', key)) + end + end, + + sub_keys = function(value, key, def) + if def.sub_keys ~= nil then + local v + -- To get keys defined with always_present + if value == nil then + v = {} + elseif type(value) == 'string' then + v = { value } + elseif type(value) == 'table' then + v = value + end + v = apply_definitions(def.sub_keys, opts, v, output[key], unknown, + add_to_key_path(key_path, key), input_root) + if utils.get_table_size(v) > 0 then + return v + end + end + end, + } + + --- standalone values are removed. + -- For some callbacks and the third return value of parse, we + -- need an unchanged raw result from the parse function. + input = clone_table(input) + if output == nil then + output = {} + end + if unknown == nil then + unknown = {} + end + if key_path == nil then + key_path = {} + end + + for index, def in pairs(defs) do + --- Find key and def + local key + if type(def) == 'table' and def.name == nil and type(index) == 'string' then + key = index + elseif type(def) == 'table' and def.name ~= nil then + key = def.name + elseif type(index) == 'number' and type(def) == 'string' then + key = def + def = { default = true } end - return table.concat(output) + + if type(def) ~= 'table' then + throw_error('Key definition must be a table') + end + + if key == nil then + throw_error('key name couldn’t be detected!') + end + + local value = find_value(key, def) + + for _, def_opt in ipairs({ + 'alias', + 'opposite_keys', + 'always_present', + 'required', + 'data_type', + 'choices', + 'match', + 'exclusive_group', + 'macro', + 'l3_tl_set', + 'process', + 'sub_keys', + }) do + if def[def_opt] ~= nil then + local tmp_value = apply[def_opt](value, key, def) + if tmp_value ~= nil then + value = tmp_value + end + end + end + + output[key] = value end - return render_inner(tbl) + + if utils.get_table_size(input) > 0 then + -- Move to the current unknown table. + local current_unknown = unknown + for _, key in ipairs(key_path) do + if current_unknown[key] == nil then + current_unknown[key] = {} + end + current_unknown = current_unknown[key] + end + + -- Copy all unknown key-value-pairs to the current unknown table. + for key, value in pairs(input) do + current_unknown[key] = value + end + end + + return output, unknown end ---- The function `stringify(tbl, for_tex)` converts a Lua table into a --- printable string. Stringify a table means to convert the table into --- a string. This function is used to realize the `print` function. --- `stringify(tbl, true)` (`for_tex = true`) generates a string which --- can be embeded into TeX documents. The macro `\luakeysdebug{}` uses --- this option. `stringify(tbl, false)` or `stringify(tbl)` generate a --- string suitable for the terminal. --- --- @tparam table input A table to stringify. --- --- @tparam boolean for_tex Stringify the table into a text string that --- can be embeded inside a TeX document via tex.print(). Curly braces --- and whites spaces are escaped. +--- Parse a LaTeX/TeX style key-value string into a Lua table. +--- +---@param kv_string string A string in the TeX/LaTeX style key-value format as described above. +---@param opts table A table containing the settings: +--- `convert_dimensions`, `unpack`, `naked_as_value`, `converter`, +--- `debug`, `preprocess`, `postprocess`. -- --- https://stackoverflow.com/a/54593224/10193818 -local function stringify(input, for_tex) - local line_break, start_bracket, end_bracket, indent - - if for_tex then - line_break = '\\par' - start_bracket = '$\\{$' - end_bracket = '$\\}$' - indent = '\\ \\ ' - else - line_break = '\n' - start_bracket = '{' - end_bracket = '}' - indent = ' ' +---@return table result The final result of all individual parsing and normalization steps. +---@return table unknown A table with unknown, undefinied key-value pairs. +---@return table raw The unprocessed, raw result of the LPeg parser. +local function parse(kv_string, opts) + if kv_string == nil then + return {} end - local function stringify_inner(input, depth) - local output = {} - depth = depth or 0 + --- Normalize the parse options. + --- + --- @param opts table Options in a raw format. The table may be empty or some keys are not set. + --- + --- @return table + local function normalize_opts(opts) + if type(opts) ~= 'table' then + opts = {} + end + for key, _ in pairs(opts) do + if all_options[key] == nil then + throw_error('Unknown parse option: ' .. tostring(key) .. '!') + end + end + local old_opts = opts + opts = {} + for name, _ in pairs(all_options) do + if old_opts[name] ~= nil then + opts[name] = old_opts[name] + else + opts[name] = default_options[name] + end + end - local function add(depth, text) - table.insert(output, string.rep(indent, depth) .. text) + local hooks = { + kv_string = true, + keys_before_opts = true, + result_before_opts = true, + keys_before_def = true, + result_before_def = true, + keys = true, + result = true, + } + + for hook in pairs(opts.hooks) do + if hooks[hook] == nil then + throw_error('Unknown hook: ' .. tostring(hook) .. '!') + end end + return opts + end + opts = normalize_opts(opts) - local function format_key(key) - if (type(key) == 'number') then - return string.format('[%s]', key) + if type(opts.hooks.kv_string) == 'function' then + kv_string = opts.hooks.kv_string(kv_string) + end + + local result = generate_parser('list', opts.convert_dimensions):match( + kv_string) + local raw = clone_table(result) + + local function apply_hook(name) + if type(opts.hooks[name]) == 'function' then + if name:match('^keys') then + result = visit_tree(result, opts.hooks[name]) else - return string.format('[\'%s\']', key) + opts.hooks[name](result) + end + + if opts.debug then + print('After the execution of the hook: ' .. name) + debug(result) end end + end - if type(input) ~= 'table' then - return tostring(input) + local function apply_hooks(at) + if at ~= nil then + at = '_' .. at + else + at = '' end + apply_hook('keys' .. at) + apply_hook('result' .. at) + end - for key, value in pairs(input) do - if (key and type(key) == 'number' or type(key) == 'string') then - key = format_key(key) + apply_hooks('before_opts') - if (type(value) == 'table') then - if (next(value)) then - add(depth, key .. ' = ' .. start_bracket) - add(0, stringify_inner(value, depth + 1)) - add(depth, end_bracket .. ','); - else - add(depth, key .. ' = ' .. start_bracket .. end_bracket .. ',') - end - else - if (type(value) == 'string') then - value = string.format('\'%s\'', value) - else - value = tostring(value) - end + --- Normalize the result table of the LPeg parser. This normalization + -- tasks are performed on the raw input table coming directly from + -- the PEG parser: + -- + --- @param result table The raw input table coming directly from the PEG parser + --- @param opts table Some options. + local function apply_opts(result, opts) + local callbacks = { + unpack = function(key, value) + if type(value) == 'table' and utils.get_array_size(value) == 1 and + utils.get_table_size(value) == 1 and type(value[1]) ~= 'table' then + return key, value[1] + end + return key, value + end, - add(depth, key .. ' = ' .. value .. ',') + process_naked = function(key, value) + if type(key) == 'number' and type(value) == 'string' then + return value, opts.default + end + return key, value + end, + + format_key = function(key, value) + if type(key) == 'string' then + for _, style in ipairs(opts.format_keys) do + if style == 'lower' then + key = key:lower() + elseif style == 'snake' then + key = key:gsub('[^%w]+', '_') + elseif style == 'upper' then + key = key:upper() + else + throw_error('Unknown style to format keys: ' .. tostring(style) .. + ' Allowed styles are: lower, snake, upper') + end + end end + return key, value + end, + } + + if opts.unpack then + result = visit_tree(result, callbacks.unpack) + end + + if not opts.naked_as_value and opts.defs == false then + result = visit_tree(result, callbacks.process_naked) + end + + if opts.format_keys then + if type(opts.format_keys) ~= 'table' then + throw_error('The option “format_keys” has to be a table not ' .. + type(opts.format_keys)) end + result = visit_tree(result, callbacks.format_key) end - return table.concat(output, line_break) + return result + end + result = apply_opts(result, opts) + + -- All unknown keys are stored in this table + local unknown = nil + if type(opts.defs) == 'table' then + apply_hooks('before_defs') + result, unknown = apply_definitions(opts.defs, opts, result, {}, {}, {}, + clone_table(result)) end - return start_bracket .. line_break .. stringify_inner(input, 1) .. line_break .. end_bracket -end + apply_hooks() ---- The function `pretty_print(tbl)` pretty prints a Lua table to standard --- output (stdout). It is a utility function that can be used to --- debug and inspect the resulting Lua table of the function --- `parse`. You have to compile your TeX document in a console to --- see the terminal output. --- --- @tparam table tbl A table to be printed to standard output for --- debugging purposes. -local function pretty_print(tbl) - print(stringify(tbl, false)) + if opts.defaults ~= nil and type(opts.defaults) == 'table' then + merge_tables(result, opts.defaults) + end + + if opts.debug then + debug(result) + end + + -- no_error + if not opts.no_error and type(unknown) == 'table' and + utils.get_table_size(unknown) > 0 then + throw_error('Unknown keys: ' .. render(unknown)) + end + return result, unknown, raw end --- Store results @@ -623,38 +1073,12 @@ end --- A table to store parsed key-value results. local result_store = {} ---- The function `save(identifier, result): void` saves a result (a --- table from a previous run of `parse`) under an identifier. --- Therefore, it is not necessary to pollute the global namespace to --- store results for the later usage. --- --- @tparam string identifier The identifier under which the result is --- saved. --- --- @tparam table result A result to be stored and that was created by --- the key-value parser. -local function save(identifier, result) - result_store[identifier] = result -end - ---- The function `get(identifier): table` retrieves a saved result --- from the result store. --- --- @tparam string identifier The identifier under which the result was --- saved. -local function get(identifier) - -- if result_store[identifier] == nil then - -- throw_error('No stored result was found for the identifier \'' .. identifier .. '\'') - -- end - return result_store[identifier] -end - --- Exports -- @section local export = { --- @see default_options - default_options = default_options, + opts = default_options, --- @see stringify stringify = stringify, @@ -662,27 +1086,58 @@ local export = { --- @see parse parse = parse, + define = function(defs, opts) + return function(kv_string, inner_opts) + local options + if inner_opts ~= nil then + options = inner_opts + elseif opts ~= nil then + options = opts + end + + if options == nil then + options = {} + end + + options.defs = defs + + return parse(kv_string, options) + end + end, + --- @see render render = render, - --- @see pretty_print - print = pretty_print, + --- @see debug + debug = debug, - --- @see save - save = save, - - --- @see get - get = get, + --- The function `save(identifier, result): void` saves a result (a + -- table from a previous run of `parse`) under an identifier. + -- Therefore, it is not necessary to pollute the global namespace to + -- store results for the later usage. + -- + -- @tparam string identifier The identifier under which the result is + -- saved. + -- + -- @tparam table result A result to be stored and that was created by + -- the key-value parser. + save = function(identifier, result) + result_store[identifier] = result + end, + + --- The function `get(identifier): table` retrieves a saved result + -- from the result store. + -- + -- @tparam string identifier The identifier under which the result was + -- saved. + get = function(identifier) + -- if result_store[identifier] == nil then + -- throw_error('No stored result was found for the identifier \'' .. identifier .. '\'') + -- end + return result_store[identifier] + end, + + is = is, } --- http://olivinelabs.com/busted/#private -if _TEST then - export.luafy_key = luafy_key - export.luafy_options = luafy_options - export.normalize = normalize - export.normalize_parse_options = normalize_parse_options - export.unpack_single_valued_array_table = unpack_single_valued_array_table - export.visit_parse_tree = visit_parse_tree -end - return export |