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

FourierTransformer / ftcsv / 30870348305

04 Aug 2026 01:57AM UTC coverage: 99.01% (+0.1%) from 98.901%
30870348305

push

github

web-flow
Merge eea6a48b7 into d8586b9f6

1195 of 1209 new or added lines in 5 files covered. (98.84%)

1 existing line in 1 file now uncovered.

1600 of 1616 relevant lines covered (99.01%)

1339.07 hits per line

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

99.51
/ftcsv.lua
1
local ftcsv = {
30✔
2
    _VERSION = 'ftcsv 1.5.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-2025 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
UNCOV
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,824✔
52
        while i <= inputLength do
11,604✔
53
            nextChar = sbyte(inputString, i+1)
11,589✔
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,589✔
58
                doubleQuoteEscape = true
279✔
59
                i = i + 2
279✔
60
                currentChar = sbyte(inputString, i)
279✔
61

62
            -- identifies the escape toggle
63
            elseif currentChar == quote and nextChar ~= quote then
11,310✔
64
                return i-1, doubleQuoteEscape
1,809✔
65
            else
66
                i = i + 1
9,501✔
67
                currentChar = nextChar
9,501✔
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,065✔
79
        if j == nil then
10,065✔
80
            return nil
75✔
81
        end
82
        difference = j - i
9,990✔
83
        if difference >= 1 then doubleQuoteEscape = true end
9,990✔
84
        if difference % 2 == 1 then
9,990✔
85
            return luaCompatibility.findClosingQuote(j+1, inputLength, inputString, quote, doubleQuoteEscape)
945✔
86
        end
87
        return j-1, doubleQuoteEscape
9,045✔
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,186✔
95
    local headerSet = {}
3,186✔
96
    for i = 1, #headerField do
12,876✔
97
        if not headerSet[headerField[i]] then
9,690✔
98
            if fieldsToKeep ~= nil and fieldsToKeep[headerField[i]] then
9,384✔
99
                table.insert(realHeaders, headerField[i])
1,800✔
100
                headerSet[headerField[i]] = true
1,800✔
101
            elseif fieldsToKeep == nil then
7,584✔
102
                table.insert(realHeaders, headerField[i])
6,834✔
103
                headerSet[headerField[i]] = true
6,834✔
104
            end
105
        end
106
    end
107
    return realHeaders
3,186✔
108
end
109

110

111
local function determineTotalColumnCount(headerField, fieldsToKeep)
112
    local totalColumnCount = 0
6,516✔
113
    local headerFieldSet = {}
6,516✔
114
    for _, header in pairs(headerField) do
16,866✔
115
        -- count unique columns and
116
        -- also figure out if it's a field to keep
117
        if not headerFieldSet[header] and
10,350✔
118
            (fieldsToKeep == nil or fieldsToKeep[header]) then
10,044✔
119
            headerFieldSet[header] = true
9,294✔
120
            totalColumnCount = totalColumnCount + 1
9,294✔
121
        end
122
    end
123
    return totalColumnCount
6,516✔
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,516✔
146
    local currentChar, nextChar = sbyte(inputString, i), nil
6,516✔
147
    local skipChar = 0
6,516✔
148
    local field
149
    local fieldStart = i
6,516✔
150
    local fieldNum = 1
6,516✔
151
    local lineNum = 1
6,516✔
152
    local lineStart = i
6,516✔
153
    local doubleQuoteEscape, emptyIdentified = false, false
6,516✔
154

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

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

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

172
    local outResults = {}
6,516✔
173

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

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

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

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

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

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

207
            -- assign field in output
208
            if headerField[fieldNum] ~= nil then
30,510✔
209
                outResults[lineNum][headerField[fieldNum]] = field
30,504✔
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
63,987✔
217
        -- go by two chars at a time,
218
        --  currentChar is set at the bottom.
219
        nextChar = sbyte(inputString, i+1)
57,567✔
220

221
        -- empty string
222
        if ignoreQuotes == false and currentChar == quote and nextChar == quote then
57,567✔
223
            skipChar = 1
696✔
224
            fieldStart = i + 2
696✔
225
            emptyIdentified = true
696✔
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
56,871✔
231
            fieldStart = i + 1
10,944✔
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
10,944✔
236
                fieldStart = fieldStart - 2
246✔
237
                emptyIdentified = false
246✔
238
            end
239
            skipChar = 1
10,944✔
240
            i, doubleQuoteEscape = luaCompatibility.findClosingQuote(i+1, inputLength, inputString, quote, doubleQuoteEscape)
12,768✔
241

242
        -- create some fields
243
        elseif currentChar == delimiterByte then
45,927✔
244
            assignValueToField()
16,818✔
245

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

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

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

260
            -- incrememnt for new line
261
            if fieldNum < totalColumnCount then
6,264✔
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,258✔
272
                outResults[lineNum] = {}
6,258✔
273
                fieldNum = 1
6,258✔
274
                fieldStart = i + 1 + skipChar
6,258✔
275
                lineStart = fieldStart
6,258✔
276
            end
277

278
        elseif luaCompatibility.LuaJIT == false then
22,845✔
279
            skipIndex = inputString:find(charPatternToSkip, i)
14,240✔
280
            if skipIndex then
14,240✔
281
                skipChar = skipIndex - i - 1
10,045✔
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
57,561✔
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,471✔
300
        if (skipChar > 0) then
57,471✔
301
            currentChar = sbyte(inputString, i)
20,413✔
302
        else
303
            currentChar = nextChar
37,058✔
304
        end
305
        skipChar = 0
57,471✔
306
    end
307

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

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

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

319
        -- indicates last field was really just a CRLF,
320
        -- so, it can be removed
321
        if fieldNum == 1 and field == "" then
1,680✔
322
            outResults[lineNum] = nil
1,674✔
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,336✔
329
end
330

331
local function handleHeaders(headerField, options)
332
    -- for files where there aren't headers!
333
    if options.headers == false then
3,198✔
334
        for j = 1, #headerField do
3,336✔
335
            headerField[j] = j
2,502✔
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,186✔
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,186✔
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,186✔
370
end
371

372
-- load an entire file into memory
373
local function loadFile(textFile, amount)
374
    local file = io.open(textFile, "r")
342✔
375
    if not file then error("ftcsv: File not found at " .. textFile) end
342✔
376
    local lines = file:read(amount)
336✔
377
    if amount == "*all" then
336✔
378
        file:close()
150✔
379
    end
380
    return lines, file
336✔
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,234✔
387
        inputString = inputFile
2,892✔
388
    else
389
        inputString, file = loadFile(inputFile, amount)
398✔
390
    end
391

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

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

404
    -- the new format for parseLine
405
    elseif type(delimiter) == "table" then
876✔
406
        local realDelimiter = delimiter.delimiter or ","
756✔
407
        return realDelimiter, delimiter
756✔
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,252✔
418

419
    local fieldsToKeep = nil
3,252✔
420

421
    if options then
3,252✔
422

423
    if options.headers ~= nil then
2,982✔
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) .. "'.")
840✔
425
    end
426

427
    if options.rename ~= nil then
2,982✔
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,982✔
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,976✔
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,904✔
447
    end
448

449
    if options.headerFunc ~= nil then
2,976✔
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,976✔
454
        options.ignoreQuotes = false
2,868✔
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,976✔
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,898✔
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,240✔
482

483
end
484

485
local function findEndOfHeaders(str, entireFile)
486
    local i = 1
3,222✔
487
    local quote = sbyte('"')
3,222✔
488
    local newlines = {
3,222✔
489
        [sbyte("\n")] = true,
3,222✔
490
        [sbyte("\r")] = true
3,222✔
491
    }
492
    local quoted = false
3,222✔
493
    local char = sbyte(str, i)
3,222✔
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
44,940✔
498
            quoted = not quoted
8,916✔
499
        end
500
        i = i + 1
44,940✔
501
        char = sbyte(str, i)
44,940✔
502
    until (newlines[char] and not quoted) or char == nil
44,940✔
503

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

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

515
local function determineBOMOffset(inputString)
516
    -- BOM files start with bytes 239, 187, 191
517
    if sbyte(inputString, 1) == 239
3,222✔
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,196✔
523
    end
524
end
525

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

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

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

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

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

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

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

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

560
---@package
561
---@class options
562
---@field delimiter? string limited to one char; default: `,`
563
---@field rename? {[string]:string} If you want to rename a field, you can set rename to change the field names.
564
---@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.
565
---@field ignoreQuotes? boolean leave all quotes in the final parsed output; default: `false`
566
---@field headerfunc? function Applies a function to every field in the header.
567
---@field headers? boolean if the file you are reading doesn't have any headers. default: `true`
568

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

572
-- runs the show!
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 file path name
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,060✔
581

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

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

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

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

590
    return output, finalHeaders
3,000✔
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
617

618
--- open a file and read options.bufferSize bytes of the file. bufferSize defaults to 2^16 bytes
619
--- (which provides the fastest parsing on most unix-based systems), or can be specified in the options. ftcsv.parseLine
620
--- is an iterator and returns one line at a time. When all the lines in the buffer are read, it will read in another
621
--- bufferSize bytes of a file and repeat the process until the entire file has been read.
622
---@param inputFile string
623
---@param delimiter? string
624
---@param userOptions? parseLineOptions
625
---@return function
626
function ftcsv.parseLine(inputFile, delimiter, userOptions)
30✔
627
    local delimiter, userOptions = determineArgumentOrder(delimiter, userOptions)
192✔
628
    local options, fieldsToKeep = parseOptions(delimiter, userOptions, true)
192✔
629
    local inputString, file = initializeInputFile(inputFile, options)
192✔
630

631

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

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

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

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

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

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

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

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

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

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

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

685

686

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

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

702
local function generateDelimitField(customToString)
703
    local delimitField = function(field)
704
        field = customToString(field)
3,582✔
705
        if field:find('"') then
3,564✔
706
            return field:gsub('"', '""')
210✔
707
        else
708
            return field
3,354✔
709
        end
710
    end
711
    return delimitField
948✔
712
end
713

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

728
local function escapeHeadersForLuaGenerator(headers)
729
    local escapedHeaders = {}
552✔
730
    for i = 1, #headers do
2,250✔
731
        if headers[i]:find('"') then
1,698✔
732
            escapedHeaders[i] = headers[i]:gsub('"', '\\"')
48✔
733
        else
734
            escapedHeaders[i] = headers[i]
1,650✔
735
        end
736
    end
737
    return escapedHeaders
552✔
738
end
739

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

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

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

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

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

779
    return luaCompatibility.load(outputFunc), arguments, 0
552✔
780

781
end
782

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

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

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

811
    return escapedHeaders
552✔
812
end
813

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

820
    -- lets make the headers alphabetical
821
    table.sort(headers)
534✔
822

823
    return headers
534✔
824
end
825

826
local function getHeadersFromOptions(options)
827
    local headers = nil
465✔
828
    if options then
558✔
829
        if options.fieldsToKeep ~= nil then
300✔
830
            assert(
48✔
831
                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) .. "'.")
24✔
832
            headers = options.fieldsToKeep
24✔
833
        end
834
    end
835
    return headers
558✔
836
end
837

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

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

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

855
---@package
856
---@class encodeOptions
857
---@field delimiter? string
858
---@field fieldsToKeep? string[]
859
---@field onlyRequiredQuotes? boolean
860
---@field encodeNilAs string|number|boolean|nil
861
---@field allowMissingKeys? string[]
862

863
-- works really quickly with luajit-2.1, because table.concat life
864
--- takes in a lua table and turns it into a text string that can be written to a file.
865
---@param inputTable table[]
866
---@param delimiter? string
867
---@param options? encodeOptions
868
---@return string
869
function ftcsv.encode(inputTable, delimiter, options)
30✔
870
    local delimiter, options = determineArgumentOrder(delimiter, options)
558✔
871
    local output, headers = initializeGenerator(inputTable, delimiter, options)
558✔
872

873
    for i, line in csvLineGenerator(inputTable, delimiter, headers, options) do
1,940✔
874
        output[i+1] = line
1,032✔
875
    end
876

877
    -- combine and return final string
878
    return table.concat(output)
552✔
879
end
880

881
return ftcsv
30✔
882

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