• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FourierTransformer / ftcsv / 30972443506

05 Aug 2026 03:30AM UTC coverage: 98.96% (-0.05%) from 99.01%
30972443506

push

github

web-flow
Merge 05d0fd94b into db8e27f7d

115 of 116 new or added lines in 2 files covered. (99.14%)

1617 of 1634 relevant lines covered (98.96%)

1334.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.27
/ftcsv.lua
1
local ftcsv = {
30✔
2
    _VERSION = 'ftcsv 1.6.0',
25✔
3
    _DESCRIPTION = 'CSV library for Lua',
25✔
4
    _URL         = 'https://github.com/FourierTransformer/ftcsv',
25✔
5
    _LICENSE     = [[
6
        The MIT License (MIT)
7

8
        Copyright (c) 2016-2026 Fourier Transformer
9

10
        Permission is hereby granted, free of charge, to any person obtaining a copy
11
        of this software and associated documentation files (the "Software"), to deal
12
        in the Software without restriction, including without limitation the rights
13
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
        copies of the Software, and to permit persons to whom the Software is
15
        furnished to do so, subject to the following conditions:
16

17
        The above copyright notice and this permission notice shall be included in all
18
        copies or substantial portions of the Software.
19

20
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
        SOFTWARE.
27
    ]]
25✔
28
}
29

30
-- perf
31
local sbyte = string.byte
30✔
32
local ssub = string.sub
30✔
33

34
-- luajit/lua compatability layer
35
local luaCompatibility = {}
30✔
36
if type(jit) == 'table' or _ENV then
30✔
37
    -- luajit and lua 5.2+
38
    luaCompatibility.load = _G.load
25✔
39
else
40
    -- lua 5.1
41
    luaCompatibility.load = loadstring
5✔
42
end
43

44
-- luajit specific speedups
45
-- luajit performs faster with iterating over string.byte,
46
-- whereas vanilla lua performs faster with string.find
47
if type(jit) == 'table' then
30✔
48
    luaCompatibility.LuaJIT = true
5✔
49
    -- finds the end of an escape sequence
50
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
5✔
51
        local currentChar, nextChar = sbyte(inputString, i), nil
1,847✔
52
        while i <= inputLength do
11,913✔
53
            nextChar = sbyte(inputString, i+1)
11,898✔
54

55
            -- this one deals with " double quotes that are escaped "" within single quotes "
56
            -- these should be turned into a single quote at the end of the field
57
            if currentChar == quote and nextChar == quote then
11,898✔
58
                doubleQuoteEscape = true
291✔
59
                i = i + 2
291✔
60
                currentChar = sbyte(inputString, i)
291✔
61

62
            -- identifies the escape toggle
63
            elseif currentChar == quote and nextChar ~= quote then
11,607✔
64
                return i-1, doubleQuoteEscape
1,832✔
65
            else
66
                i = i + 1
9,775✔
67
                currentChar = nextChar
9,775✔
68
            end
69
        end
70
    end
71

72
else
73
    luaCompatibility.LuaJIT = false
25✔
74

75
    -- vanilla lua closing quote finder
76
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
25✔
77
        local j, difference
78
        i, j = inputString:find('"+', i)
10,210✔
79
        if j == nil then
10,210✔
80
            return nil
75✔
81
        end
82
        difference = j - i
10,135✔
83
        if difference >= 1 then doubleQuoteEscape = true end
10,135✔
84
        if difference % 2 == 1 then
10,135✔
85
            return luaCompatibility.findClosingQuote(j+1, inputLength, inputString, quote, doubleQuoteEscape)
975✔
86
        end
87
        return j-1, doubleQuoteEscape
9,160✔
88
    end
89
end
90

91

92
-- determine the real headers as opposed to the header mapping
93
local function determineRealHeaders(headerField, fieldsToKeep)
94
    local realHeaders = {}
3,198✔
95
    local headerSet = {}
3,198✔
96
    for i = 1, #headerField do
12,924✔
97
        if not headerSet[headerField[i]] then
9,726✔
98
            if fieldsToKeep ~= nil and fieldsToKeep[headerField[i]] then
9,420✔
99
                table.insert(realHeaders, headerField[i])
1,800✔
100
                headerSet[headerField[i]] = true
1,800✔
101
            elseif fieldsToKeep == nil then
7,620✔
102
                table.insert(realHeaders, headerField[i])
6,870✔
103
                headerSet[headerField[i]] = true
6,870✔
104
            end
105
        end
106
    end
107
    return realHeaders
3,198✔
108
end
109

110

111
local function determineTotalColumnCount(headerField, fieldsToKeep)
112
    local totalColumnCount = 0
6,540✔
113
    local headerFieldSet = {}
6,540✔
114
    for _, header in pairs(headerField) do
16,926✔
115
        -- count unique columns and
116
        -- also figure out if it's a field to keep
117
        if not headerFieldSet[header] and
10,386✔
118
            (fieldsToKeep == nil or fieldsToKeep[header]) then
10,080✔
119
            headerFieldSet[header] = true
9,330✔
120
            totalColumnCount = totalColumnCount + 1
9,330✔
121
        end
122
    end
123
    return totalColumnCount
6,540✔
124
end
125

126
local function generateHeadersMetamethod(finalHeaders)
127
    -- if a header field tries to escape, we will simply return nil
128
    -- the parser will still parse, but wont get the performance benefit of
129
    -- having headers predefined
130
    for _, headers in ipairs(finalHeaders) do
8,772✔
131
        if headers:find("]") then
6,426✔
132
            return nil
6✔
133
        end
134
    end
135
    local rawSetup = "local t, k, _ = ... \
136
    rawset(t, k, {[ [[%s]] ]=true})"
2,346✔
137
    rawSetup = rawSetup:format(table.concat(finalHeaders, "]] ]=true, [ [["))
2,346✔
138
    return luaCompatibility.load(rawSetup)
2,346✔
139
end
140

141
-- main function used to parse
142
local function parseString(inputString, i, options)
143

144
    -- keep track of my chars!
145
    local inputLength = options.inputLength or #inputString
6,540✔
146
    local currentChar, nextChar = sbyte(inputString, i), nil
6,540✔
147
    local skipChar = 0
6,540✔
148
    local field
149
    local fieldStart = i
6,540✔
150
    local fieldNum = 1
6,540✔
151
    local lineNum = 1
6,540✔
152
    local lineStart = i
6,540✔
153
    local doubleQuoteEscape, emptyIdentified = false, false
6,540✔
154

155
    local skipIndex
156
    local charPatternToSkip = "[" .. options.delimiter .. "\r\n]"
6,540✔
157

158
    --bytes
159
    local CR = sbyte("\r")
6,540✔
160
    local LF = sbyte("\n")
6,540✔
161
    local quote = sbyte('"')
6,540✔
162
    local delimiterByte = sbyte(options.delimiter)
6,540✔
163

164
    -- explode most used options
165
    local headersMetamethod = options.headersMetamethod
6,540✔
166
    local fieldsToKeep = options.fieldsToKeep
6,540✔
167
    local ignoreQuotes = options.ignoreQuotes
6,540✔
168
    local headerField = options.headerField
6,540✔
169
    local endOfFile = options.endOfFile
6,540✔
170
    local buffered = options.buffered
6,540✔
171

172
    local outResults = {}
6,540✔
173

174
    -- in the first run, the headers haven't been set yet.
175
    if headerField == nil then
6,540✔
176
        headerField = {}
3,210✔
177
        -- setup a metatable to simply return the key that's passed in
178
        local headerMeta = {__index = function(_, key) return key end}
28,938✔
179
        setmetatable(headerField, headerMeta)
3,210✔
180
    end
181

182
    if headersMetamethod then
6,540✔
183
        setmetatable(outResults, {__newindex = headersMetamethod})
2,478✔
184
    end
185
    outResults[1] = {}
6,540✔
186

187
    -- totalColumnCount based on unique headers and fieldsToKeep
188
    local totalColumnCount = options.totalColumnCount or determineTotalColumnCount(headerField, fieldsToKeep)
6,540✔
189

190
    local function assignValueToField()
191
        if fieldsToKeep == nil or fieldsToKeep[headerField[fieldNum]] then
29,664✔
192

193
            -- create new field
194
            if ignoreQuotes == false and sbyte(inputString, i-1) == quote then
28,608✔
195
                field = ssub(inputString, fieldStart, i-2)
12,719✔
196
            else
197
                field = ssub(inputString, fieldStart, i-1)
20,657✔
198
            end
199
            if doubleQuoteEscape then
28,608✔
200
                field = field:gsub('""', '"')
852✔
201
            end
202

203
            -- reset flags
204
            doubleQuoteEscape = false
28,608✔
205
            emptyIdentified = false
28,608✔
206

207
            -- assign field in output
208
            if headerField[fieldNum] ~= nil then
30,752✔
209
                outResults[lineNum][headerField[fieldNum]] = field
30,746✔
210
            else
211
                error('ftcsv: too many columns in row ' .. options.rowOffset + lineNum)
6✔
212
            end
213
        end
214
    end
215

216
    while i <= inputLength do
64,482✔
217
        -- go by two chars at a time,
218
        --  currentChar is set at the bottom.
219
        nextChar = sbyte(inputString, i+1)
58,038✔
220

221
        -- empty string
222
        if ignoreQuotes == false and currentChar == quote and nextChar == quote then
58,038✔
223
            skipChar = 1
702✔
224
            fieldStart = i + 2
702✔
225
            emptyIdentified = true
702✔
226

227
        -- escape toggle.
228
        -- This can only happen if fields have quotes around them
229
        -- so the current "start" has to be where a quote character is.
230
        elseif ignoreQuotes == false and currentChar == quote and nextChar ~= quote and fieldStart == i then
57,336✔
231
            fieldStart = i + 1
11,082✔
232
            -- if an empty field was identified before assignment, it means
233
            -- that this is a quoted field that starts with escaped quotes
234
            -- ex: """a"""
235
            if emptyIdentified then
11,082✔
236
                fieldStart = fieldStart - 2
246✔
237
                emptyIdentified = false
246✔
238
            end
239
            skipChar = 1
11,082✔
240
            i, doubleQuoteEscape = luaCompatibility.findClosingQuote(i+1, inputLength, inputString, quote, doubleQuoteEscape)
12,929✔
241

242
        -- create some fields
243
        elseif currentChar == delimiterByte then
46,254✔
244
            assignValueToField()
16,962✔
245

246
            -- increaseFieldIndices
247
            fieldNum = fieldNum + 1
16,962✔
248
            fieldStart = i + 1
16,962✔
249

250
        -- newline
251
        elseif (currentChar == LF or currentChar == CR) then
29,292✔
252
            assignValueToField()
6,330✔
253

254
            -- handle CRLF
255
            if (currentChar == CR and nextChar == LF) then
6,330✔
256
                skipChar = 1
2,814✔
257
                fieldStart = fieldStart + 1
2,814✔
258
            end
259

260
            -- incrememnt for new line
261
            if fieldNum < totalColumnCount then
6,330✔
262
                -- sometimes in buffered mode, the buffer starts with a newline
263
                -- this skips the newline and lets the parsing continue.
264
                if buffered and lineNum == 1 and fieldNum == 1 and field == "" then
6✔
265
                    fieldStart = i + 1 + skipChar
×
266
                    lineStart = fieldStart
×
267
                else
268
                    error('ftcsv: too few columns in row ' .. options.rowOffset + lineNum)
6✔
269
                end
270
            else
271
                lineNum = lineNum + 1
6,324✔
272
                outResults[lineNum] = {}
6,324✔
273
                fieldNum = 1
6,324✔
274
                fieldStart = i + 1 + skipChar
6,324✔
275
                lineStart = fieldStart
6,324✔
276
            end
277

278
        elseif luaCompatibility.LuaJIT == false then
22,962✔
279
            skipIndex = inputString:find(charPatternToSkip, i)
14,295✔
280
            if skipIndex then
14,295✔
281
                skipChar = skipIndex - i - 1
10,100✔
282
            end
283

284
        end
285

286
        -- in buffered mode and it can't find the closing quote
287
        -- it usually means in the middle of a buffer and need to backtrack
288
        if i == nil then
58,032✔
289
            if buffered then
90✔
290
                outResults[lineNum] = nil
84✔
291
                return outResults, lineStart
84✔
292
            else
293
                error("ftcsv: can't find closing quote in row " .. options.rowOffset + lineNum ..
12✔
294
                 ". Try running with the option ignoreQuotes=true if the source incorrectly uses quotes.")
6✔
295
            end
296
        end
297

298
        -- Increment Counter
299
        i = i + 1 + skipChar
57,942✔
300
        if (skipChar > 0) then
57,942✔
301
            currentChar = sbyte(inputString, i)
20,648✔
302
        else
303
            currentChar = nextChar
37,294✔
304
        end
305
        skipChar = 0
57,942✔
306
    end
307

308
    if buffered and not endOfFile then
6,444✔
309
        outResults[lineNum] = nil
72✔
310
        return outResults, lineStart
72✔
311
    end
312

313
    -- create last new field
314
    assignValueToField()
6,372✔
315

316
    -- remove last field if empty
317
    if fieldNum < totalColumnCount then
6,366✔
318

319
        -- indicates last field was really just a CRLF,
320
        -- so, it can be removed
321
        if fieldNum == 1 and field == "" then
1,686✔
322
            outResults[lineNum] = nil
1,680✔
323
        else
324
            error('ftcsv: too few columns in row ' .. options.rowOffset + lineNum)
6✔
325
        end
326
    end
327

328
    return outResults, i, totalColumnCount
6,360✔
329
end
330

331
local function handleHeaders(headerField, options)
332
    -- for files where there aren't headers!
333
    if options.headers == false then
3,210✔
334
        for j = 1, #headerField do
3,384✔
335
            headerField[j] = j
2,538✔
336
        end
337
    else
338
        -- make sure a header isn't empty if there are headers
339
        for _, headerName in ipairs(headerField) do
9,564✔
340
            if #headerName == 0 then
7,212✔
341
                error('ftcsv: Cannot parse a file which contains empty headers')
12✔
342
            end
343
        end
344
    end
345

346
    -- rename fields as needed!
347
    if options.rename then
3,198✔
348
        -- basic rename (["a" = "apple"])
349
        for j = 1, #headerField do
4,968✔
350
            if options.rename[headerField[j]] then
3,732✔
351
                headerField[j] = options.rename[headerField[j]]
2,634✔
352
            end
353
        end
354
        -- files without headers, but with a options.rename need to be handled too!
355
        if #options.rename > 0 then
1,236✔
356
            for j = 1, #options.rename do
1,698✔
357
                headerField[j] = options.rename[j]
1,236✔
358
            end
359
        end
360
    end
361

362
    -- apply some sweet header manipulation
363
    if options.headerFunc then
3,198✔
364
        for j = 1, #headerField do
1,176✔
365
            headerField[j] = options.headerFunc(headerField[j])
1,029✔
366
        end
367
    end
368

369
    return headerField
3,198✔
370
end
371

372
-- load an entire file into memory
373
local function loadFile(textFile, amount)
374
    local file = io.open(textFile, "r")
348✔
375
    if not file then error("ftcsv: File not found at " .. textFile) end
348✔
376
    local lines = file:read(amount)
342✔
377
    if amount == "*all" then
342✔
378
        file:close()
156✔
379
    end
380
    return lines, file
342✔
381
end
382

383
local function initializeInputFromStringOrFile(inputFile, options, amount)
384
    -- handle input via string or file!
385
    local inputString, file
386
    if options.loadFromString then
3,246✔
387
        inputString = inputFile
2,898✔
388
    else
389
        inputString, file = loadFile(inputFile, amount)
405✔
390
    end
391

392
    -- if they sent in an empty file...
393
    if inputString == "" then
3,240✔
394
        error('ftcsv: Cannot parse an empty file')
6✔
395
    end
396
    return inputString, file
3,234✔
397
end
398

399
local function determineArgumentOrder(delimiter, options)
400
    -- backwards compatibile layer
401
    if type(delimiter) == "string" then
3,828✔
402
        return delimiter, options
2,946✔
403

404
    -- the new format for parseLine
405
    elseif type(delimiter) == "table" then
882✔
406
        local realDelimiter = delimiter.delimiter or ","
762✔
407
        return realDelimiter, delimiter
762✔
408

409
    -- if nothing is specified, assume "," delimited and call it a day!
410
    else
411
        return ",", nil
120✔
412
    end
413
end
414

415
local function parseOptions(delimiter, options, fromParseLine)
416
    -- delimiter MUST be one character
417
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
3,264✔
418

419
    local fieldsToKeep = nil
3,264✔
420

421
    if options then
3,264✔
422

423
    if options.headers ~= nil then
2,994✔
424
        assert(type(options.headers) == "boolean", "ftcsv only takes the boolean 'true' or 'false' for the optional parameter 'headers' (default 'true'). You passed in '" .. tostring(options.headers) .. "' of type '" .. type(options.headers) .. "'.")
852✔
425
    end
426

427
    if options.rename ~= nil then
2,994✔
428
        assert(type(options.rename) == "table", "ftcsv only takes in a key-value table for the optional parameter 'rename'. You passed in '" .. tostring(options.rename) .. "' of type '" .. type(options.rename) .. "'.")
1,236✔
429
    end
430

431
    if options.fieldsToKeep ~= nil then
2,994✔
432
        assert(type(options.fieldsToKeep) == "table", "ftcsv only takes in a list (as a table) for the optional parameter 'fieldsToKeep'. You passed in '" .. tostring(options.fieldsToKeep) .. "' of type '" .. type(options.fieldsToKeep) .. "'.")
906✔
433
        local ofieldsToKeep = options.fieldsToKeep
906✔
434
        if ofieldsToKeep ~= nil then
906✔
435
            fieldsToKeep = {}
906✔
436
            for j = 1, #ofieldsToKeep do
2,718✔
437
                fieldsToKeep[ofieldsToKeep[j]] = true
1,812✔
438
            end
439
        end
440
        if options.headers == false and options.rename == nil then
906✔
441
            error("ftcsv: fieldsToKeep only works with header-less files when using the 'rename' functionality")
6✔
442
        end
443
    end
444

445
    if options.loadFromString ~= nil then
2,988✔
446
        assert(type(options.loadFromString) == "boolean", "ftcsv only takes a boolean value for optional parameter 'loadFromString'. You passed in '" .. tostring(options.loadFromString) .. "' of type '" .. type(options.loadFromString) .. "'.")
2,910✔
447
    end
448

449
    if options.headerFunc ~= nil then
2,988✔
450
        assert(type(options.headerFunc) == "function", "ftcsv only takes a function value for optional parameter 'headerFunc'. You passed in '" .. tostring(options.headerFunc) .. "' of type '" .. type(options.headerFunc) .. "'.")
294✔
451
    end
452

453
    if options.ignoreQuotes == nil then
2,988✔
454
        options.ignoreQuotes = false
2,880✔
455
    else
456
        assert(type(options.ignoreQuotes) == "boolean", "ftcsv only takes a boolean value for optional parameter 'ignoreQuotes'. You passed in '" .. tostring(options.ignoreQuotes) .. "' of type '" .. type(options.ignoreQuotes) .. "'.")
108✔
457
    end
458

459
    if fromParseLine == true then
2,988✔
460
        if options.bufferSize == nil then
78✔
461
            options.bufferSize = 2^16
18✔
462
        else
463
            assert(type(options.bufferSize) == "number", "ftcsv only takes a number value for optional parameter 'bufferSize'. You passed in '" .. tostring(options.bufferSize) .. "' of type '" .. type(options.bufferSize) .. "'.")
60✔
464
        end
465

466
    else
467
        if options.bufferSize ~= nil then
2,910✔
468
            error("ftcsv: bufferSize can only be specified using 'parseLine'. When using 'parse', the entire file is read into memory")
6✔
469
        end
470
    end
471

472
    else
473
        options = {
270✔
474
            ["headers"] = true,
225✔
475
            ["loadFromString"] = false,
225✔
476
            ["ignoreQuotes"] = false,
225✔
477
            ["bufferSize"] = 2^16
225✔
478
        }
225✔
479
    end
480

481
    return options, fieldsToKeep
3,252✔
482

483
end
484

485
local function findEndOfHeaders(str, entireFile)
486
    local i = 1
3,234✔
487
    local quote = sbyte('"')
3,234✔
488
    local newlines = {
3,234✔
489
        [sbyte("\n")] = true,
3,234✔
490
        [sbyte("\r")] = true
3,234✔
491
    }
492
    local quoted = false
3,234✔
493
    local char = sbyte(str, i)
3,234✔
494
    repeat
495
        -- this should still work for escaped quotes
496
        -- ex: " a "" b \r\n " -- there is always a pair around the newline
497
        if char == quote then
45,192✔
498
            quoted = not quoted
8,964✔
499
        end
500
        i = i + 1
45,192✔
501
        char = sbyte(str, i)
45,192✔
502
    until (newlines[char] and not quoted) or char == nil
45,192✔
503

504
    if not entireFile and char == nil then
3,234✔
505
        error("ftcsv: bufferSize needs to be larger to parse this file")
24✔
506
    end
507

508
    local nextChar = sbyte(str, i+1)
3,210✔
509
    if nextChar == sbyte("\n") and char == sbyte("\r") then
3,210✔
510
        i = i + 1
1,254✔
511
    end
512
    return i
3,210✔
513
end
514

515
local function determineBOMOffset(inputString)
516
    -- BOM files start with bytes 239, 187, 191
517
    if sbyte(inputString, 1) == 239
3,234✔
518
        and sbyte(inputString, 2) == 187
1,026✔
519
        and sbyte(inputString, 3) == 191 then
1,026✔
520
        return 4
1,026✔
521
    else
522
        return 1
2,208✔
523
    end
524
end
525

526
local function parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, entireFile)
527
    local startLine = determineBOMOffset(inputString)
3,234✔
528

529
    local endOfHeaderRow = findEndOfHeaders(inputString, entireFile)
3,234✔
530

531
    local parserArgs = {
3,210✔
532
        delimiter = delimiter,
3,210✔
533
        headerField = nil,
2,675✔
534
        fieldsToKeep = nil,
2,675✔
535
        inputLength = endOfHeaderRow,
3,210✔
536
        buffered = false,
2,675✔
537
        ignoreQuotes = options.ignoreQuotes,
3,210✔
538
        rowOffset = 0
2,675✔
539
    }
540

541
    local rawHeaders, endOfHeaders = parseString(inputString, startLine, parserArgs)
3,210✔
542

543
    -- manipulate the headers as per the options
544
    local modifiedHeaders = handleHeaders(rawHeaders[1], options)
3,210✔
545
    parserArgs.headerField = modifiedHeaders
3,198✔
546
    parserArgs.fieldsToKeep = fieldsToKeep
3,198✔
547
    parserArgs.inputLength = nil
3,198✔
548

549
    if options.headers == false then endOfHeaders = startLine end
3,198✔
550

551
    local finalHeaders = determineRealHeaders(modifiedHeaders, fieldsToKeep)
3,198✔
552
    if options.headers ~= false then
3,198✔
553
        local headersMetamethod = generateHeadersMetamethod(finalHeaders)
2,352✔
554
        parserArgs.headersMetamethod = headersMetamethod
2,352✔
555
    end
556

557
    return endOfHeaders, parserArgs, finalHeaders
3,198✔
558
end
559

560
---@package
561
---@class options
562
---@field delimiter? string If your file doesn't use the comma character as the delimiter, you can specify your own. It is limited to one character and defaults to `,`
563
---@field loadFromString? boolean If you want to load a csv from a string instead of a file, set `loadFromString` to `true` and pass CSV data as the first argument
564
---@field rename? {[string]:string} If you want to rename a field, you can set `rename` to change the field names
565
---@field fieldsToKeep? string[] If you only want to keep certain fields from the CSV, send them in as a table-list and it should parse a little faster and use less memory.
566
---@field ignoreQuotes? boolean If `ignoreQuotes` is `true`, it will leave all quotes in the final parsed output. This is useful in situations where the fields aren't quoted, but contain quotes, or if the CSV didn't handle quotes correctly and you're trying to parse it.
567
---@field headerfunc? function Applies a function to every field in the header. If you are using `rename`, the function is applied after the rename.
568
---@field headers? boolean Set `headers` to `false` if the file you are reading doesn't have any headers. This will cause ftcsv to create indexed tables rather than a key-value tables for the output.
569

570
---@class parseOptions : options
571
---@field loadFromString? boolean load a csv from a string instead of a file; default: `false`
572

573
--load the entire csv file into memory, then parse it in one go, returning a lua table
574
--- with the parsed data and a lua table containing the column headers.
575
---@param inputFile string filepath
576
---@param delimiter? string
577
---@param options? parseOptions
578
---@return table[] output, string[] headers
579
function ftcsv.parse(inputFile, delimiter, options)
30✔
580
    local delimiter, options = determineArgumentOrder(delimiter, options)
3,072✔
581

582
    local options, fieldsToKeep = parseOptions(delimiter, options, false)
3,072✔
583

584
    local inputString = initializeInputFromStringOrFile(inputFile, options, "*all")
3,060✔
585

586
    local endOfHeaders, parserArgs, finalHeaders = parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, true)
3,048✔
587

588
    local output = parseString(inputString, endOfHeaders, parserArgs)
3,036✔
589

590
    return output, finalHeaders
3,012✔
591
end
592

593
local function getFileSize(file)
594
    local current = file:seek()
186✔
595
    local size = file:seek("end")
186✔
596
    file:seek("set", current)
186✔
597
    return size
186✔
598
end
599

600
local function determineAtEndOfFile(file, fileSize)
601
    if file:seek() >= fileSize then
318✔
602
        return true
138✔
603
    else
604
        return false
180✔
605
    end
606
end
607

608
local function initializeInputFile(inputString, options)
609
    if options.loadFromString == true then
192✔
610
        error("ftcsv: parseLine currently doesn't support loading from string")
6✔
611
    end
612
    return initializeInputFromStringOrFile(inputString, options, options.bufferSize)
186✔
613
end
614

615
---@class parseLineOptions : options
616
---@field bufferSize? integer The size of the buffer used during reading (defaults to 2^16 bytes)
617

618
--- ftcsv.parseLine returns one line at a time while reading the file in bytes by a fixed amount at a time (`bufferSize`)
619
--- until the entire file has been read.
620
---@param inputFile string filepath
621
---@param delimiter? string
622
---@param userOptions? parseLineOptions
623
---@return function
624
function ftcsv.parseLine(inputFile, delimiter, userOptions)
30✔
625
    local delimiter, userOptions = determineArgumentOrder(delimiter, userOptions)
192✔
626
    local options, fieldsToKeep = parseOptions(delimiter, userOptions, true)
192✔
627
    local inputString, file = initializeInputFile(inputFile, options)
192✔
628

629

630
    local fileSize, atEndOfFile = 0, false
186✔
631
    fileSize = getFileSize(file)
217✔
632
    atEndOfFile = determineAtEndOfFile(file, fileSize)
217✔
633

634
    local endOfHeaders, parserArgs, _ = parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, atEndOfFile)
186✔
635
    parserArgs.buffered = true
162✔
636
    parserArgs.endOfFile = atEndOfFile
162✔
637

638
    local parsedBuffer, endOfParsedInput, totalColumnCount = parseString(inputString, endOfHeaders, parserArgs)
162✔
639
    parserArgs.totalColumnCount = totalColumnCount
162✔
640

641
    inputString = ssub(inputString, endOfParsedInput)
189✔
642
    local bufferIndex, returnedRowsCount = 0, 0
162✔
643
    local currentRow, buffer
644

645
    return function()
646
        -- check parsed buffer for value
647
        bufferIndex = bufferIndex + 1
552✔
648
        currentRow = parsedBuffer[bufferIndex]
552✔
649
        if currentRow then
552✔
650
            returnedRowsCount = returnedRowsCount + 1
282✔
651
            return returnedRowsCount, currentRow
282✔
652
        end
653

654
        -- read more of the input
655
        buffer = file:read(options.bufferSize)
270✔
656
        if not buffer then
270✔
657
            file:close()
138✔
658
            return nil
138✔
659
        else
660
            parserArgs.endOfFile = determineAtEndOfFile(file, fileSize)
154✔
661
        end
662

663
        -- appends the new input to what was left over
664
        inputString = inputString .. buffer
132✔
665

666
        -- re-analyze and load buffer
667
        parserArgs.rowOffset = returnedRowsCount
132✔
668
        parsedBuffer, endOfParsedInput = parseString(inputString, 1, parserArgs)
154✔
669
        bufferIndex = 1
132✔
670

671
        -- cut the input string down
672
        inputString = ssub(inputString, endOfParsedInput)
154✔
673

674
        if #parsedBuffer == 0 then
132✔
675
            error("ftcsv: bufferSize needs to be larger to parse this file")
24✔
676
        end
677

678
        returnedRowsCount = returnedRowsCount + 1
108✔
679
        return returnedRowsCount, parsedBuffer[bufferIndex]
108✔
680
    end
681
end
682

683

684

685
-- The ENCODER code is below here
686
-- This could be broken out, but is kept here for portability
687

688
local function generateCustomToString(valueToConvertNilTo)
689
    local newReturnValue = tostring(valueToConvertNilTo)
36✔
690
    local generatedFunction = function(field)
691
        if type(field) == "nil" then
324✔
692
            return newReturnValue
108✔
693
        else
694
            return tostring(field)
216✔
695
        end
696
    end
697
    return generatedFunction
36✔
698
end
699

700
local function generateDelimitField(customToString)
701
    local delimitField = function(field)
702
        field = customToString(field)
3,690✔
703
        if field:find('"') then
3,672✔
704
            return field:gsub('"', '""')
228✔
705
        else
706
            return field
3,444✔
707
        end
708
    end
709
    return delimitField
960✔
710
end
711

712
local function generateDelimitAndQuoteField(delimiter, customToString)
713
    local generatedFunction = function(field)
714
        field = customToString(field)
1,464✔
715
        if field:find('"') then
1,428✔
716
            return '"' .. field:gsub('"', '""') .. '"'
78✔
717
        elseif field:find('[\n' .. delimiter .. ']') then
1,350✔
718
            return '"' .. field .. '"'
72✔
719
        else
720
            return field
1,278✔
721
        end
722
    end
723
    return generatedFunction
312✔
724
end
725

726
local function escapeHeadersForLuaGenerator(headers)
727
    local escapedHeaders = {}
558✔
728
    for i = 1, #headers do
2,274✔
729
        if headers[i]:find('"') then
1,716✔
730
            escapedHeaders[i] = headers[i]:gsub('"', '\\"')
48✔
731
        else
732
            escapedHeaders[i] = headers[i]
1,668✔
733
        end
734
    end
735
    return escapedHeaders
558✔
736
end
737

738
-- a function that compiles some lua code to quickly print out the csv
739
local function csvLineGenerator(inputTable, delimiter, headers, options)
740
    local escapedHeaders = escapeHeadersForLuaGenerator(headers)
558✔
741

742
    local outputFunc = [[
743
        local args, i = ...
744
        i = i + 1;
745
        if i > ]] .. #inputTable .. [[ then return nil end;
558✔
746
        return i, '"' .. args.delimitField(args.t[i]["]] ..
558✔
747
            table.concat(escapedHeaders, [["]) .. '"]] ..
1,116✔
748
            delimiter .. [["' .. args.delimitField(args.t[i]["]]) ..
930✔
749
            [["]) .. '"\r\n']]
558✔
750

751
    if options and options.onlyRequiredQuotes == true then
558✔
752
        outputFunc = [[
753
            local args, i = ...
754
            i = i + 1;
755
            if i > ]] .. #inputTable .. [[ then return nil end;
156✔
756
            return i, args.delimitField(args.t[i]["]] ..
156✔
757
                table.concat(escapedHeaders, [["]) .. ']] ..
312✔
758
                delimiter .. [[' .. args.delimitField(args.t[i]["]]) ..
260✔
759
                [["]) .. '\r\n']]
208✔
760
    end
761

762
    local arguments = {}
558✔
763
    arguments.t = inputTable
558✔
764
    -- we want to use the same delimitField throughout,
765
    -- so we're just going to pass it in
766

767
    local toStringToUse = tostring
558✔
768
    if options and options.encodeNilAs ~= nil then
558✔
769
        toStringToUse = generateCustomToString(options.encodeNilAs)
42✔
770
    end
771
    if options and options.onlyRequiredQuotes == true then
558✔
772
        arguments.delimitField = generateDelimitAndQuoteField(delimiter, toStringToUse)
182✔
773
    else
774
        arguments.delimitField = generateDelimitField(toStringToUse)
469✔
775
    end
776

777
    return luaCompatibility.load(outputFunc), arguments, 0
558✔
778

779
end
780

781
local function validateHeaders(headers, inputTable)
782
    for i = 1, #headers do
1,194✔
783
        if inputTable[1][headers[i]] == nil then
900✔
784
            error("ftcsv: the field '" .. headers[i] .. "' doesn't exist in the inputTable")
6✔
785
        end
786
    end
787
end
788

789
local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
790
    local output = {}
558✔
791
    if options and options.onlyRequiredQuotes == true then
558✔
792
        output[1] = table.concat(escapedHeaders, delimiter) .. '\r\n'
156✔
793
    else
794
        output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n'
402✔
795
    end
796
    return output
558✔
797
end
798

799
local function escapeHeadersForOutput(headers, delimiter, options)
800
    local escapedHeaders = {}
558✔
801
    local delimitField = generateDelimitField(tostring)
558✔
802
    if options and options.onlyRequiredQuotes == true then
558✔
803
        delimitField = generateDelimitAndQuoteField(delimiter, tostring)
182✔
804
    end
805
    for i = 1, #headers do
2,274✔
806
        escapedHeaders[i] = delimitField(headers[i])
2,002✔
807
    end
808

809
    return escapedHeaders
558✔
810
end
811

812
local function extractHeadersFromTable(inputTable)
813
    local headers = {}
534✔
814
    for key, _ in pairs(inputTable[1]) do
2,184✔
815
        headers[#headers+1] = key
1,650✔
816
    end
817

818
    -- lets make the headers alphabetical
819
    table.sort(headers)
534✔
820

821
    return headers
534✔
822
end
823

824
local function getHeadersFromOptions(options)
825
    local headers = nil
470✔
826
    if options then
564✔
827
        if options.headers == false and options.fieldsToKeep == nil then
306✔
NEW
828
            error("`fieldsToKeep` must be specified if generating a CSV without a header.")
×
829
        end
830
        if options.fieldsToKeep ~= nil then
306✔
831
            assert(
60✔
832
                type(options.fieldsToKeep) == "table", "ftcsv only takes in a list (as a table) for the optional parameter 'fieldsToKeep'. You passed in '" .. tostring(options.headers) .. "' of type '" .. type(options.headers) .. "'.")
30✔
833
            headers = options.fieldsToKeep
30✔
834
        end
835
    end
836
    return headers
564✔
837
end
838

839
local function initializeGenerator(inputTable, delimiter, options)
840
    -- delimiter MUST be one character
841
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
564✔
842

843
    local headers = getHeadersFromOptions(options)
564✔
844
    if headers == nil then
564✔
845
        headers = extractHeadersFromTable(inputTable)
623✔
846
    end
847
    if options and options.allowMissingKeys == nil then
564✔
848
        validateHeaders(headers, inputTable)
300✔
849
    end
850

851
    local escapedHeaders = escapeHeadersForOutput(headers, delimiter, options)
558✔
852
    local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
558✔
853
    return output, headers
558✔
854
end
855

856
---@package
857
---@class encodeOptions
858
---@field delimiter? string the delimiter in the encoded output can be changed by setting a value for `delimiter`, by default it is `,`
859
---@field fieldsToKeep? string[] if `fieldsToKeep` is set in the encode process, only the fields specified will be written out to a file. The `fieldsToKeep` will be written out in the order that is specified.
860
---@field onlyRequiredQuotes? boolean if `onlyRequiredQuotes` is set to `true`, the output will only include quotes around fields that are quotes, have newlines, or contain the delimiter.
861
---@field encodeNilAs string|number|boolean|nil The value a `nil` value in the a table can be set with `encodeNilAs`. By default a `nil` value in a table will be encoded as the string `"nil"`.
862
---@field allowMissingKeys? boolean If set to a non-`nil` value, this option allows encoding data sets that are entirely missing a field that was specified in `fieldsToKeep`. Otherwise, ftcsv would raise an error.
863
---@field headers? boolean If set to `false` it only outputs the data without the headers. This can be useful for streaming a CSV in chunks.
864

865
-- takes in a lua table, encodes it as csv, and returns a string that can be written to a file.
866
---@param inputTable table[]
867
---@param delimiter? string
868
---@param options? encodeOptions
869
---@return string
870
function ftcsv.encode(inputTable, delimiter, options)
30✔
871
    local delimiter, options = determineArgumentOrder(delimiter, options)
564✔
872
    local output, headers = initializeGenerator(inputTable, delimiter, options)
564✔
873
    local offset = 1
558✔
874
    if options and options.headers == false then offset = 0 end
558✔
875

876
    for i, line in csvLineGenerator(inputTable, delimiter, headers, options) do
1,983✔
877
        output[i+offset] = line
1,062✔
878
    end
879

880
    -- combine and return final string
881
    return table.concat(output)
558✔
882
end
883

884
return ftcsv
30✔
885

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc