Check options passed to csv parser

This commit is contained in:
2025-11-15 16:06:31 +01:00
parent aec0f9f171
commit bf23894188
2 changed files with 224 additions and 40 deletions

View File

@@ -53,6 +53,34 @@ end
--- @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
--- @return boolean #True if options are valid
--- @return string? #Error message if invalid, nil if valid
function areOptionsValid(options)
if options == nil then return true, nil end
if type(options) ~= "table" then return false, "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
return false,
"unknown option: " .. tostring(k) .. " (valid options: " .. validOptionsStr .. ")"
end
end
return true, nil
end
--- Parses CSV text into rows and fields using a minimal RFC 4180 state machine.
---
--- Requirements/assumptions:
@@ -66,16 +94,22 @@ end
--- - 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.
--- - 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.
--- @return table #A table (array) of rows; each row is a table with numeric indices and optionally header-named keys.
--- @return string? #Error message if parsing fails.
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
-- Validate options
local isValid, err = areOptionsValid(options)
if not isValid then return {}, err end
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 = {}
@@ -186,12 +220,13 @@ function fromCSV(csv, options)
row[headerName] = dataRow[j]
end
end
row.Headers = headers
table.insert(rows, row)
end
return rows
return rows, nil
end
return allRows
return allRows, nil
end
--- Converts a table of rows back to CSV text format (RFC 4180 compliant).
@@ -202,14 +237,46 @@ end
--- - 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 delimiter string? The field delimiter (default: ",").
--- @return string CSV-formatted text.
function toCSV(rows, delimiter)
if delimiter == nil then delimiter = "," end
--- @param options ParserOptions? Options for the parser
--- @return string? #CSV-formatted text, error string?
--- @return string? #Error message if conversion fails.
function toCSV(rows, options)
if options == nil then options = {} end
-- Validate options
local isValid, err = areOptionsValid(options)
if not isValid then return nil, err end
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[1].Headers ~= nil then
local headerStrings = {}
for _, header in ipairs(rows[1].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 = {}
@@ -237,7 +304,7 @@ function toCSV(rows, delimiter)
table.insert(rowStrings, table.concat(fieldStrings, delimiter))
end
return table.concat(rowStrings, "\n")
return table.concat(rowStrings, "\n"), nil
end
-- String to number conversion helper