Files
BigChef/processor/luahelper.lua
2025-12-19 13:24:44 +01:00

625 lines
19 KiB
Lua

-- Custom Lua helpers for math operations
--- Returns the minimum of two numbers
--- @param a number First number
--- @param b number Second number
--- @return number Minimum value
function min(a, b) return math.min(a, b) end
--- Returns the maximum of two numbers
--- @param a number First number
--- @param b number Second number
--- @return number Maximum value
function max(a, b) return math.max(a, b) end
--- Rounds a number to n decimal places
--- @param x number Number to round
--- @param n number? Number of decimal places (default: 0)
--- @return number Rounded number
function round(x, n)
if n == nil then n = 0 end
return math.floor(x * 10 ^ n + 0.5) / 10 ^ n
end
--- Returns the floor of a number
--- @param x number Number to floor
--- @return number Floored number
function floor(x) return math.floor(x) end
--- Returns the ceiling of a number
--- @param x number Number to ceil
--- @return number Ceiled number
function ceil(x) return math.ceil(x) end
--- Converts string to uppercase
--- @param s string String to convert
--- @return string Uppercase string
function upper(s) return string.upper(s) end
--- Converts string to lowercase
--- @param s string String to convert
--- @return string Lowercase string
function lower(s) return string.lower(s) end
--- Formats a string using Lua string.format
--- @param s string Format string
--- @param ... any Values to format
--- @return string Formatted string
function format(s, ...) return string.format(s, ...) end
--- Removes leading and trailing whitespace from string
--- @param s string String to trim
--- @return string Trimmed string
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
--- Splits a string by separator
--- @param inputstr string String to split
--- @param sep string? Separator pattern (default: whitespace)
--- @return table Array of string parts
function strsplit(inputstr, sep)
if sep == nil then sep = "%s" end
local t = {}
for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
table.insert(t, str)
end
return t
end
---@param table table
---@param depth number?
function dump(table, depth)
if depth == nil then depth = 0 end
if depth > 200 then
print("Error: Depth > 200 in dump()")
return
end
for k, v in pairs(table) do
if type(v) == "table" then
print(string.rep(" ", depth) .. k .. ":")
dump(v, depth + 1)
else
print(string.rep(" ", depth) .. k .. ": ", v)
end
end
end
--- @class ParserOptions
--- @field delimiter string? The field delimiter (default: ",").
--- @field hasheader boolean? If true, first non-comment row is treated as headers (default: false).
--- @field hascomments boolean? If true, lines starting with '#' are skipped (default: false).
--- @type ParserOptions
parserDefaultOptions = { delimiter = ",", hasheader = false, hascomments = false }
--- Validates options against a set of valid option keys.
--- @param options ParserOptions? The options table to validate
function areOptionsValid(options)
if options == nil then return end
if type(options) ~= "table" then error("options must be a table") end
-- Build valid options list from validOptions table
local validOptionsStr = ""
for k, _ in pairs(parserDefaultOptions) do
validOptionsStr = validOptionsStr .. k .. ", "
end
for k, _ in pairs(options) do
if parserDefaultOptions[k] == nil then
error(
"unknown option: " .. tostring(k) .. " (valid options: " .. validOptionsStr .. ")"
)
end
end
end
--- Parses CSV text into rows and fields using a minimal RFC 4180 state machine.
---
--- Requirements/assumptions:
--- - Input is a single string containing the entire CSV content.
--- - Field separators are specified by delimiter option (default: comma).
--- - Newlines between rows may be "\n" or "\r\n". "\r\n" is treated as one line break.
--- - Fields may be quoted with double quotes (").
--- - Inside quoted fields, doubled quotes ("") represent a literal quote character.
--- - No backslash escaping is supported (not part of RFC 4180).
--- - Newlines inside quoted fields are preserved as part of the field.
--- - Leading/trailing spaces are preserved; no trimming is performed.
--- - Empty fields and empty rows are preserved.
--- - The final row is emitted even if the text does not end with a newline.
--- - Lines starting with '#' (after optional leading whitespace) are treated as comments and skipped if hascomments is true.
---
--- @param csv string The CSV text to parse.
--- @param options ParserOptions? Options for the parser
--- @return table #A table (array) of rows; each row is a table with numeric indices and optionally header-named keys.
function fromCSV(csv, options)
if options == nil then options = {} end
-- Validate options
areOptionsValid(options)
local delimiter = options.delimiter or parserDefaultOptions.delimiter
local hasheader = options.hasheader or parserDefaultOptions.hasheader
local hascomments = options.hascomments or parserDefaultOptions.hascomments
local allRows = {}
local fields = {}
local field = {}
local STATE_DEFAULT = 1
local STATE_IN_QUOTES = 2
local STATE_QUOTE_IN_QUOTES = 3
local state = STATE_DEFAULT
local i = 1
local len = #csv
while i <= len do
local c = csv:sub(i, i)
if state == STATE_DEFAULT then
if c == '"' then
state = STATE_IN_QUOTES
i = i + 1
elseif c == delimiter then
table.insert(fields, table.concat(field))
field = {}
i = i + 1
elseif c == "\r" or c == "\n" then
table.insert(fields, table.concat(field))
field = {}
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = trim(firstField)
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
fields = {}
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
i = i + 2
else
i = i + 1
end
else
table.insert(field, c)
i = i + 1
end
elseif state == STATE_IN_QUOTES then
if c == '"' then
state = STATE_QUOTE_IN_QUOTES
i = i + 1
else
table.insert(field, c)
i = i + 1
end
else -- STATE_QUOTE_IN_QUOTES
if c == '"' then
table.insert(field, '"')
state = STATE_IN_QUOTES
i = i + 1
elseif c == delimiter then
table.insert(fields, table.concat(field))
field = {}
state = STATE_DEFAULT
i = i + 1
elseif c == "\r" or c == "\n" then
table.insert(fields, table.concat(field))
field = {}
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
fields = {}
state = STATE_DEFAULT
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
i = i + 2
else
i = i + 1
end
else
state = STATE_DEFAULT
-- Don't increment i, reprocess character in DEFAULT state
end
end
end
if #field > 0 or #fields > 0 then
table.insert(fields, table.concat(field))
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
end
if hasheader and #allRows > 0 then
local headers = allRows[1]
local headerMap = {}
for j = 1, #headers do
if headers[j] ~= nil and headers[j] ~= "" then
local headerName = trim(headers[j])
headerMap[headerName] = j
end
end
local header_mt = {
headers = headerMap,
__index = function(t, key)
local mt = getmetatable(t)
if type(key) == "string" and mt.headers and mt.headers[key] then
return rawget(t, mt.headers[key])
end
return rawget(t, key)
end,
__newindex = function(t, key, value)
local mt = getmetatable(t)
if type(key) == "string" and mt.headers then
if mt.headers[key] then
rawset(t, mt.headers[key], value)
else
error("unknown header: " .. tostring(key))
end
else
rawset(t, key, value)
end
end,
}
local rows = {}
for ii = 2, #allRows do
local row = {}
local dataRow = allRows[ii]
for j = 1, #dataRow do
row[j] = dataRow[j]
end
setmetatable(row, header_mt)
table.insert(rows, row)
end
rows.Headers = headers
return rows
end
return allRows
end
--- Converts a table of rows back to CSV text format (RFC 4180 compliant).
---
--- Requirements:
--- - Input is a table (array) of rows, where each row is a table (array) of field values.
--- - Field values are converted to strings using tostring().
--- - Fields are quoted if they contain the delimiter, newlines, or double quotes.
--- - Double quotes inside quoted fields are doubled ("").
--- - Fields are joined with the specified delimiter; rows are joined with newlines.
--- - If includeHeaders is true and rows have a Headers field, headers are included as the first row.
---
--- @param rows table Array of rows, where each row is an array of field values.
--- @param options ParserOptions? Options for the parser
--- @return string #CSV-formatted text
function toCSV(rows, options)
if options == nil then options = {} end
-- Validate options
areOptionsValid(options)
local delimiter = options.delimiter or parserDefaultOptions.delimiter
local includeHeaders = options.hasheader or parserDefaultOptions.hasheader
local rowStrings = {}
-- Include headers row if requested and available
if includeHeaders and #rows > 0 and rows.Headers ~= nil then
local headerStrings = {}
for _, header in ipairs(rows.Headers) do
local headerStr = tostring(header)
local needsQuoting = false
if
headerStr:find(delimiter)
or headerStr:find("\n")
or headerStr:find("\r")
or headerStr:find('"')
then
needsQuoting = true
end
if needsQuoting then
headerStr = headerStr:gsub('"', '""')
headerStr = '"' .. headerStr .. '"'
end
table.insert(headerStrings, headerStr)
end
table.insert(rowStrings, table.concat(headerStrings, delimiter))
end
for _, row in ipairs(rows) do
local fieldStrings = {}
for _, field in ipairs(row) do
local fieldStr = tostring(field)
local needsQuoting = false
if
fieldStr:find(delimiter)
or fieldStr:find("\n")
or fieldStr:find("\r")
or fieldStr:find('"')
then
needsQuoting = true
end
if needsQuoting then
fieldStr = fieldStr:gsub('"', '""')
fieldStr = '"' .. fieldStr .. '"'
end
table.insert(fieldStrings, fieldStr)
end
table.insert(rowStrings, table.concat(fieldStrings, delimiter))
end
return table.concat(rowStrings, "\n")
end
--- Converts string to number, returns 0 if invalid
--- @param str string String to convert
--- @return number Numeric value or 0
function num(str) return tonumber(str) or 0 end
--- Converts number to string
--- @param num number Number to convert
--- @return string String representation
function str(num) return tostring(num) end
--- Checks if string is numeric
--- @param str string String to check
--- @return boolean True if string is numeric
function is_number(str) return tonumber(str) ~= nil end
--- Checks if table is a sequential array (1-indexed with no gaps)
--- @param t table Table to check
--- @return boolean True if table is an array
function isArray(t)
if type(t) ~= "table" then return false end
local max = 0
local count = 0
for k, _ in pairs(t) do
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return false end
max = math.max(max, k)
count = count + 1
end
return max == count
end
modified = false
-- ============================================================================
-- XML HELPER FUNCTIONS
-- ============================================================================
--- Find all elements with a specific tag name (recursive search)
--- @param root table The root XML element (with _tag, _attr, _children fields)
--- @param tagName string The tag name to search for
--- @return table Array of matching elements
function findElements(root, tagName)
local results = {}
local function search(element)
if element._tag == tagName then table.insert(results, element) end
if element._children then
for _, child in ipairs(element._children) do
search(child)
end
end
end
search(root)
return results
end
--- Visit all elements recursively and call a function on each
--- @param root table The root XML element
--- @param callback function Function to call with each element: callback(element, depth, path)
function visitElements(root, callback)
local function visit(element, depth, path)
callback(element, depth, path)
if element._children then
for i, child in ipairs(element._children) do
local childPath = path .. "/" .. child._tag .. "[" .. i .. "]"
visit(child, depth + 1, childPath)
end
end
end
visit(root, 0, "/" .. root._tag)
end
--- Get numeric value from XML element attribute
--- @param element table XML element with _attr field
--- @param attrName string Attribute name
--- @return number|nil The numeric value or nil if not found/not numeric
function getNumAttr(element, attrName)
if not element._attr then return nil end
local value = element._attr[attrName]
if not value then return nil end
return tonumber(value)
end
--- Set numeric value to XML element attribute
--- @param element table XML element with _attr field
--- @param attrName string Attribute name
--- @param value number Numeric value to set
function setNumAttr(element, attrName, value)
if not element._attr then element._attr = {} end
element._attr[attrName] = tostring(value)
end
--- Modify numeric attribute by applying a function
--- @param element table XML element
--- @param attrName string Attribute name
--- @param func function Function that takes current value and returns new value
--- @return boolean True if modification was made
function modifyNumAttr(element, attrName, func)
local current = getNumAttr(element, attrName)
if current then
setNumAttr(element, attrName, func(current))
return true
end
return false
end
--- Find all elements matching a predicate function
--- @param root table The root XML element
--- @param predicate function Function that takes element and returns true/false
--- @return table Array of matching elements
function filterElements(root, predicate)
local results = {}
visitElements(root, function(element)
if predicate(element) then table.insert(results, element) end
end)
return results
end
--- Get text content of an element
--- @param element table XML element
--- @return string|nil The text content or nil
function getText(element) return element._text end
--- Set text content of an element
--- @param element table XML element
--- @param text string Text content to set
function setText(element, text) element._text = text end
--- Check if element has an attribute
--- @param element table XML element
--- @param attrName string Attribute name
--- @return boolean True if attribute exists
function hasAttr(element, attrName) return element._attr and element._attr[attrName] ~= nil end
--- Get attribute value as string
--- @param element table XML element
--- @param attrName string Attribute name
--- @return string|nil The attribute value or nil
function getAttr(element, attrName)
if not element._attr then return nil end
return element._attr[attrName]
end
--- Set attribute value
--- @param element table XML element
--- @param attrName string Attribute name
--- @param value any Value to set (will be converted to string)
function setAttr(element, attrName, value)
if not element._attr then element._attr = {} end
element._attr[attrName] = tostring(value)
end
--- Find first element with a specific tag name (searches direct children only)
--- @param parent table The parent XML element
--- @param tagName string The tag name to search for
--- @return table|nil The first matching element or nil
function findFirstElement(parent, tagName)
if not parent._children then return nil end
for _, child in ipairs(parent._children) do
if child._tag == tagName then return child end
end
return nil
end
--- Add a child element to a parent
--- @param parent table The parent XML element
--- @param child table The child element to add
function addChild(parent, child)
if not parent._children then parent._children = {} end
table.insert(parent._children, child)
end
--- Remove all children with a specific tag name
--- @param parent table The parent XML element
--- @param tagName string The tag name to remove
--- @return number Count of removed children
function removeChildren(parent, tagName)
if not parent._children then return 0 end
local removed = 0
local i = 1
while i <= #parent._children do
if parent._children[i]._tag == tagName then
table.remove(parent._children, i)
removed = removed + 1
else
i = i + 1
end
end
return removed
end
--- Get all direct children with a specific tag name
--- @param parent table The parent XML element
--- @param tagName string The tag name to search for
--- @return table Array of matching children
function getChildren(parent, tagName)
local results = {}
if not parent._children then return results end
for _, child in ipairs(parent._children) do
if child._tag == tagName then table.insert(results, child) end
end
return results
end
--- Count children with a specific tag name
--- @param parent table The parent XML element
--- @param tagName string The tag name to count
--- @return number Count of matching children
function countChildren(parent, tagName)
if not parent._children then return 0 end
local count = 0
for _, child in ipairs(parent._children) do
if child._tag == tagName then count = count + 1 end
end
return count
end
-- ============================================================================
-- JSON HELPER FUNCTIONS
-- ============================================================================
--- Recursively visit all values in a JSON structure
--- @param data table JSON data (nested tables)
--- @param callback function Function called with (value, key, parent)
function visitJSON(data, callback)
local function visit(obj, key, parent)
callback(obj, key, parent)
if type(obj) == "table" then
for k, v in pairs(obj) do
visit(v, k, obj)
end
end
end
visit(data, nil, nil)
end
--- Find all values in JSON matching a predicate
--- @param data table JSON data
--- @param predicate function Function that takes (value, key, parent) and returns true/false
--- @return table Array of matching values
function findInJSON(data, predicate)
local results = {}
visitJSON(data, function(value, key, parent)
if predicate(value, key, parent) then table.insert(results, value) end
end)
return results
end
--- Modify all numeric values in JSON matching a condition
--- @param data table JSON data
--- @param predicate function Function that takes (value, key, parent) and returns true/false
--- @param modifier function Function that takes current value and returns new value
function modifyJSONNumbers(data, predicate, modifier)
visitJSON(data, function(value, key, parent)
if type(value) == "number" and predicate(value, key, parent) then
if parent and key then parent[key] = modifier(value) end
end
end)
end