Files
BigChef/processor/luahelper.lua
PhatPhuckDave a4bbaf9f27 Fix up the lua tests
To be less retarded...
2025-12-19 13:22:17 +01:00

581 lines
17 KiB
Lua

-- Custom Lua helpers for math operations
function min(a, b) return math.min(a, b) end
function max(a, b) return math.max(a, b) end
function round(x, n)
if n == nil then n = 0 end
return math.floor(x * 10 ^ n + 0.5) / 10 ^ n
end
function floor(x) return math.floor(x) end
function ceil(x) return math.ceil(x) end
function upper(s) return string.upper(s) end
function lower(s) return string.lower(s) end
function format(s, ...) return string.format(s, ...) end
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
-- String split helper
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
-- String to number conversion helper
function num(str) return tonumber(str) or 0 end
-- Number to string conversion
function str(num) return tostring(num) end
-- Check if string is numeric
function is_number(str) return tonumber(str) ~= nil end
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