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