-- 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). --- 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 local delimiter = options.delimiter or "," local hasheader = options.hasheader or false local hascomments = options.hascomments or false 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 rows = {} for ii = 2, #allRows do local row = {} local dataRow = allRows[ii] for j = 1, #dataRow do row[j] = dataRow[j] if headers[j] ~= nil and headers[j] ~= "" then local headerName = trim(headers[j]) row[headerName] = dataRow[j] end end table.insert(rows, row) end 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. --- --- @param rows table Array of rows, where each row is an array of field values. --- @param delimiter string? The field delimiter (default: ","). --- @return string CSV-formatted text. function toCSV(rows, delimiter) if delimiter == nil then delimiter = "," end local rowStrings = {} 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