-- 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