262 lines
7.4 KiB
Lua
262 lines
7.4 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 hasHeaders 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 hasHeaders = options.hasHeaders 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 = 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 = {}
|
|
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 hasHeaders and #allRows > 0 then
|
|
local headers = allRows[1]
|
|
local rows = {}
|
|
for i = 2, #allRows do
|
|
local row = {}
|
|
local dataRow = allRows[i]
|
|
for j = 1, #dataRow do
|
|
row[j] = dataRow[j]
|
|
if headers[j] ~= nil and headers[j] ~= "" then row[headers[j]] = 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
|