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

@@ -15,10 +15,29 @@ local function test(name, fn)
end
end
-- Test fromCSV option validation
test("fromCSV invalid option", function()
local csv = "a,b,c\n1,2,3"
local rows, err = fromCSV(csv, { invalidOption = true })
assert(rows ~= nil and #rows == 0, "Should return empty table on error")
assert(err ~= nil, "Should return error message")
assert(string.find(err, "unknown option"), "Error should mention unknown option")
end)
-- Test toCSV error handling
test("toCSV invalid delimiter", function()
local rows = { { "a", "b", "c" } }
local csv, err = toCSV(rows, { delimiter = 123 })
-- toCSV converts delimiter to string, so 123 becomes "123"
assert(csv == "a123b123c", "Should convert delimiter to string")
assert(err == nil, "Should not return error")
end)
-- Test fromCSV basic parsing
test("fromCSV basic", function()
local csv = "a,b,c\n1,2,3\n4,5,6"
local rows = fromCSV(csv)
local rows, err = fromCSV(csv)
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows")
assert(rows[1][1] == "a", "First row first field should be 'a'")
assert(rows[2][2] == "2", "Second row second field should be '2'")
@@ -27,7 +46,8 @@ end)
-- Test fromCSV with headers
test("fromCSV with headers", function()
local csv = "foo,bar,baz\n1,2,3\n4,5,6"
local rows = fromCSV(csv, { hasHeaders = true })
local rows, err = fromCSV(csv, { hasheader = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1][1] == "1", "First row first field should be '1'")
assert(rows[1].foo == "1", "First row foo should be '1'")
@@ -38,7 +58,8 @@ end)
-- Test fromCSV with custom delimiter
test("fromCSV with tab delimiter", function()
local csv = "a\tb\tc\n1\t2\t3"
local rows = fromCSV(csv, { delimiter = "\t" })
local rows, err = fromCSV(csv, { delimiter = "\t" })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "a", "First row first field should be 'a'")
assert(rows[2][2] == "2", "Second row second field should be '2'")
@@ -47,7 +68,8 @@ end)
-- Test fromCSV with quoted fields
test("fromCSV with quoted fields", function()
local csv = '"hello,world","test"\n"foo","bar"'
local rows = fromCSV(csv)
local rows, err = fromCSV(csv)
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "hello,world", "Quoted field with comma should be preserved")
assert(rows[1][2] == "test", "Second field should be 'test'")
@@ -56,37 +78,44 @@ end)
-- Test toCSV basic
test("toCSV basic", function()
local rows = { { "a", "b", "c" }, { "1", "2", "3" } }
local csv = toCSV(rows)
local csv, err = toCSV(rows)
if err then error("toCSV error: " .. err) end
assert(csv == "a,b,c\n1,2,3", "CSV output should match expected")
end)
-- Test toCSV with custom delimiter
test("toCSV with tab delimiter", function()
local rows = { { "a", "b", "c" }, { "1", "2", "3" } }
local csv = toCSV(rows, "\t")
local csv, err = toCSV(rows, { delimiter = "\t" })
if err then error("toCSV error: " .. err) end
assert(csv == "a\tb\tc\n1\t2\t3", "TSV output should match expected")
end)
-- Test toCSV with fields needing quoting
test("toCSV with quoted fields", function()
local rows = { { "hello,world", "test" }, { "foo", "bar" } }
local csv = toCSV(rows)
local csv, err = toCSV(rows)
if err then error("toCSV error: " .. err) end
assert(csv == '"hello,world",test\nfoo,bar', "Fields with commas should be quoted")
end)
-- Test round trip
test("fromCSV toCSV round trip", function()
local original = "a,b,c\n1,2,3\n4,5,6"
local rows = fromCSV(original)
local csv = toCSV(rows)
local rows, err = fromCSV(original)
if err then error("fromCSV error: " .. err) end
local csv, err = toCSV(rows)
if err then error("toCSV error: " .. err) end
assert(csv == original, "Round trip should preserve original")
end)
-- Test round trip with headers
test("fromCSV toCSV round trip with headers", function()
local original = "foo,bar,baz\n1,2,3\n4,5,6"
local rows = fromCSV(original, { hasHeaders = true })
local csv = toCSV(rows)
local rows, err = fromCSV(original, { hasheader = true })
if err then error("fromCSV error: " .. err) end
local csv, err = toCSV(rows)
if err then error("toCSV error: " .. err) end
local expected = "1,2,3\n4,5,6"
assert(csv == expected, "Round trip with headers should preserve data rows")
end)
@@ -94,7 +123,8 @@ end)
-- Test fromCSV with comments
test("fromCSV with comments", function()
local csv = "# This is a comment\nfoo,bar,baz\n1,2,3\n# Another comment\n4,5,6"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (comments filtered, header + 2 data rows)")
assert(rows[1][1] == "foo", "First row should be header row")
assert(rows[2][1] == "1", "Second row first field should be '1'")
@@ -104,7 +134,8 @@ end)
-- Test fromCSV with comments and headers
test("fromCSV with comments and headers", function()
local csv = "#mercenary_profiles\nId,Name,Value\n1,Test,100\n# End of data\n2,Test2,200"
local rows = fromCSV(csv, { hasHeaders = true, hasComments = true })
local rows, err = fromCSV(csv, { hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1].Id == "1", "First row Id should be '1'")
assert(rows[1].Name == "Test", "First row Name should be 'Test'")
@@ -115,7 +146,8 @@ end)
-- Test fromCSV with comments disabled
test("fromCSV without comments", function()
local csv = "# This should not be filtered\nfoo,bar\n1,2"
local rows = fromCSV(csv, { hasComments = false })
local rows, err = fromCSV(csv, { hascomments = false })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (including comment)")
assert(rows[1][1] == "# This should not be filtered", "Comment line should be preserved")
end)
@@ -123,7 +155,8 @@ end)
-- Test fromCSV with comment at start
test("fromCSV comment at start", function()
local csv = "# Header comment\nId,Name\n1,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
@@ -131,7 +164,8 @@ end)
-- Test fromCSV with comment with leading whitespace
test("fromCSV comment with whitespace", function()
local csv = " # Comment with spaces\nId,Name\n1,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (comment with spaces filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
@@ -139,7 +173,8 @@ end)
-- Test fromCSV with comment with tabs
test("fromCSV comment with tabs", function()
local csv = "\t# Comment with tab\nId,Name\n1,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (comment with tab filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
@@ -147,7 +182,8 @@ end)
-- Test fromCSV with multiple consecutive comments
test("fromCSV multiple consecutive comments", function()
local csv = "# First comment\n# Second comment\n# Third comment\nId,Name\n1,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (all comments filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
@@ -155,7 +191,8 @@ end)
-- Test fromCSV with comment in middle of data
test("fromCSV comment in middle", function()
local csv = "Id,Name\n1,Test\n# Middle comment\n2,Test2"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row should be first data")
@@ -165,7 +202,8 @@ end)
-- Test fromCSV with comment at end
test("fromCSV comment at end", function()
local csv = "Id,Name\n1,Test\n# End comment"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (end comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row should be data")
@@ -174,7 +212,8 @@ end)
-- Test fromCSV with empty comment line
test("fromCSV empty comment", function()
local csv = "#\nId,Name\n1,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows (empty comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
@@ -182,7 +221,8 @@ end)
-- Test fromCSV with comment and headers
test("fromCSV comment with headers enabled", function()
local csv = "#mercenary_profiles\nId,Name,Value\n1,Test,100\n2,Test2,200"
local rows = fromCSV(csv, { hasHeaders = true, hasComments = true })
local rows, err = fromCSV(csv, { hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1].Id == "1", "First row Id should be '1'")
assert(rows[1].Name == "Test", "First row Name should be 'Test'")
@@ -192,7 +232,8 @@ end)
-- Test fromCSV with comment and TSV delimiter
test("fromCSV comment with tab delimiter", function()
local csv = "# Comment\nId\tName\n1\tTest"
local rows = fromCSV(csv, { delimiter = "\t", hasComments = true })
local rows, err = fromCSV(csv, { delimiter = "\t", hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row first field should be '1'")
@@ -201,7 +242,8 @@ end)
-- Test fromCSV with comment and headers and TSV
test("fromCSV comment with headers and TSV", function()
local csv = "#mercenary_profiles\nId\tName\tValue\n1\tTest\t100"
local rows = fromCSV(csv, { delimiter = "\t", hasHeaders = true, hasComments = true })
local rows, err = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 1, "Should have 1 data row")
assert(rows[1].Id == "1", "Row Id should be '1'")
assert(rows[1].Name == "Test", "Row Name should be 'Test'")
@@ -211,7 +253,8 @@ end)
-- Test fromCSV with data field starting with # (not a comment)
test("fromCSV data field starting with hash", function()
local csv = "Id,Name\n1,#NotAComment\n2,Test"
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (data with # not filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][2] == "#NotAComment", "Second row should have #NotAComment as data")
@@ -220,7 +263,8 @@ end)
-- Test fromCSV with quoted field starting with #
test("fromCSV quoted field with hash", function()
local csv = 'Id,Name\n1,"#NotAComment"\n2,Test'
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (quoted # not filtered)")
assert(rows[2][2] == "#NotAComment", "Quoted field with # should be preserved")
end)
@@ -228,7 +272,8 @@ end)
-- Test fromCSV with comment after quoted field
test("fromCSV comment after quoted field", function()
local csv = 'Id,Name\n1,"Test"\n# This is a comment\n2,Test2'
local rows = fromCSV(csv, { hasComments = true })
local rows, err = fromCSV(csv, { hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows (comment filtered)")
assert(rows[2][2] == "Test", "Quoted field should be preserved")
assert(rows[3][1] == "2", "Third row should be second data row")
@@ -362,13 +407,85 @@ test("isArray function", function()
assert(isArray(123) == false, "isArray should return false for number")
end)
test("toCSV assigns header keys correctly", function()
test("fromCSV assigns header keys correctly", function()
local teststr = [[
#mercenary_profiles
Id ModifyStartCost ModifyStep ModifyLevelLimit Health ResistSheet WoundSlots MeleeDamage MeleeAccuracy RangeAccuracy ReceiveAmputationChance ReceiveWoundChanceMult AttackWoundChanceMult Dodge Los StarvationLimit PainThresholdLimit PainThresholdRegen TalentPerkId ActorId SkinIndex HairType HairColorHex VoiceBank Immunity CreatureClass
john_hawkwood_boss 20 0.1 140 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.60 critchance 0.05 0.5 0.5 0.03 0.5 1.2 0.3 8 2200 16 2 talent_the_man_who_sold_the_world human_male 0 hair1 #633D08 player Human
francis_reid_daly 20 0.1 130 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.5 0.4 0.04 0.9 1 0.3 8 2000 10 1 talent_weapon_durability human_male 0 player Human
]]
local rows, err = fromCSV(teststr, { delimiter = "\t", hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 2, "Should have 2 data rows")
-- Test first row
assert(rows[1].Id == "john_hawkwood_boss", "First row Id should be 'john_hawkwood_boss'")
assert(rows[1].ModifyStartCost == "20", "First row ModifyStartCost should be '20'")
assert(rows[1].ModifyStep == "0.1", "First row ModifyStep should be '0.1'")
assert(rows[1].Health == "140", "First row Health should be '140'")
assert(rows[1].ActorId == "human_male", "First row ActorId should be 'human_male'")
assert(rows[1].HairColorHex == "#633D08", "First row HairColorHex should be '#633D08'")
-- Test second row
assert(rows[2].Id == "francis_reid_daly", "Second row Id should be 'francis_reid_daly'")
assert(rows[2].ModifyStartCost == "20", "Second row ModifyStartCost should be '20'")
assert(rows[2].ModifyStep == "0.1", "Second row ModifyStep should be '0.1'")
assert(rows[2].Health == "130", "Second row Health should be '130'")
assert(rows[2].ActorId == "human_male", "Second row ActorId should be 'human_male'")
-- Test that numeric indices still work
assert(rows[1][1] == "john_hawkwood_boss", "First row first field by index should work")
assert(rows[1][2] == "20", "First row second field by index should work")
-- Debug: Print first row keys to verify headers are assigned
print("DEBUG: First row keys:")
for k, v in pairs(rows[1]) do
if type(k) == "string" then print(" " .. k .. " = " .. tostring(v)) end
end
end)
test("fromCSV debug header assignment", function()
local csv = "Id Name Value\n1 Test 100\n2 Test2 200"
local rows, err = fromCSV(csv, { delimiter = "\t", hasheader = true })
if err then error("fromCSV error: " .. err) end
print("DEBUG: Number of rows: " .. #rows)
if #rows > 0 then
print("DEBUG: First row Id = " .. tostring(rows[1].Id))
print("DEBUG: First row Name = " .. tostring(rows[1].Name))
print("DEBUG: First row Value = " .. tostring(rows[1].Value))
print("DEBUG: First row keys:")
for k, v in pairs(rows[1]) do
print(" " .. tostring(k) .. " (" .. type(k) .. ") = " .. tostring(v))
end
end
assert(rows[1].Id == "1", "Id should be '1'")
assert(rows[1].Name == "Test", "Name should be 'Test'")
assert(rows[1].Value == "100", "Value should be '100'")
end)
test("fromCSV real world mercenary file format", function()
local csv = [[#mercenary_profiles
Id ModifyStartCost ModifyStep ModifyLevelLimit Health ResistSheet WoundSlots MeleeDamage MeleeAccuracy RangeAccuracy ReceiveAmputationChance ReceiveWoundChanceMult AttackWoundChanceMult Dodge Los StarvationLimit PainThresholdLimit PainThresholdRegen TalentPerkId ActorId SkinIndex HairType HairColorHex VoiceBank Immunity CreatureClass
john_hawkwood_boss 20 0.1 140 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.60 critchance 0.05 0.5 0.5 0.03 0.5 1.2 0.3 8 2200 16 2 talent_the_man_who_sold_the_world human_male 0 hair1 #633D08 player Human
francis_reid_daly 20 0.1 130 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.5 0.4 0.04 0.9 1 0.3 8 2000 10 1 talent_weapon_durability human_male 0 player Human
]]
local rows, err = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
print("DEBUG: Number of rows: " .. #rows)
assert(#rows == 2, "Should have 2 data rows")
if #rows > 0 then
print("DEBUG: First row Id = " .. tostring(rows[1].Id))
print("DEBUG: First row ModifyStartCost = " .. tostring(rows[1].ModifyStartCost))
print("DEBUG: First row all string keys:")
for k, v in pairs(rows[1]) do
if type(k) == "string" then print(" " .. tostring(k) .. " = " .. tostring(v)) end
end
end
assert(rows[1].Id == "john_hawkwood_boss", "First row Id should be 'john_hawkwood_boss'")
assert(rows[1].ModifyStartCost == "20", "First row ModifyStartCost should be '20'")
assert(rows[2].Id == "francis_reid_daly", "Second row Id should be 'francis_reid_daly'")
end)
print("\nAll tests completed!")

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