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

FourierTransformer / ftcsv / 18484137408

14 Oct 2025 03:02AM UTC coverage: 34.942% (+0.05%) from 34.891%
18484137408

push

github

web-flow
Allow encoding data with missing fields. (#50)

12 of 12 new or added lines in 2 files covered. (100.0%)

25 existing lines in 4 files now uncovered.

5619 of 16081 relevant lines covered (34.94%)

480.77 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',
20✔
3
    _DESCRIPTION = 'CSV library for Lua',
20✔
4
    _URL         = 'https://github.com/FourierTransformer/ftcsv',
20✔
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
    ]]
20✔
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✔
UNCOV
48
    luaCompatibility.LuaJIT = true
10✔
49
    -- finds the end of an escape sequence
UNCOV
50
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
10✔
UNCOV
51
        local currentChar, nextChar = sbyte(inputString, i), nil
3,648✔
UNCOV
52
        while i <= inputLength do
23,208✔
UNCOV
53
            nextChar = sbyte(inputString, i+1)
23,178✔
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
UNCOV
57
            if currentChar == quote and nextChar == quote then
23,178✔
UNCOV
58
                doubleQuoteEscape = true
558✔
UNCOV
59
                i = i + 2
558✔
UNCOV
60
                currentChar = sbyte(inputString, i)
558✔
61

62
            -- identifies the escape toggle
UNCOV
63
            elseif currentChar == quote and nextChar ~= quote then
22,620✔
UNCOV
64
                return i-1, doubleQuoteEscape
3,618✔
65
            else
UNCOV
66
                i = i + 1
19,002✔
UNCOV
67
                currentChar = nextChar
19,002✔
68
            end
69
        end
70
    end
71

72
else
73
    luaCompatibility.LuaJIT = false
20✔
74

75
    -- vanilla lua closing quote finder
76
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
20✔
77
        local j, difference
78
        i, j = inputString:find('"+', i)
8,052✔
79
        if j == nil then
8,052✔
80
            return nil
60✔
81
        end
82
        difference = j - i
7,992✔
83
        if difference >= 1 then doubleQuoteEscape = true end
7,992✔
84
        if difference % 2 == 1 then
7,992✔
85
            return luaCompatibility.findClosingQuote(j+1, inputLength, inputString, quote, doubleQuoteEscape)
756✔
86
        end
87
        return j-1, doubleQuoteEscape
7,236✔
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)
14,344✔
196
            else
197
                field = ssub(inputString, fieldStart, i-1)
23,488✔
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
32,646✔
209
                outResults[lineNum][headerField[fieldNum]] = field
32,640✔
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
69,744✔
217
        -- go by two chars at a time,
218
        --  currentChar is set at the bottom.
219
        nextChar = sbyte(inputString, i+1)
63,324✔
220

221
        -- empty string
222
        if ignoreQuotes == false and currentChar == quote and nextChar == quote then
63,324✔
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
62,628✔
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)
14,592✔
241

242
        -- create some fields
243
        elseif currentChar == delimiterByte then
51,684✔
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
34,866✔
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
28,602✔
279
            skipIndex = inputString:find(charPatternToSkip, i)
11,392✔
280
            if skipIndex then
11,392✔
281
                skipChar = skipIndex - i - 1
8,036✔
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
63,318✔
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
63,228✔
300
        if (skipChar > 0) then
63,228✔
301
            currentChar = sbyte(inputString, i)
19,196✔
302
        else
303
            currentChar = nextChar
44,032✔
304
        end
305
        skipChar = 0
63,228✔
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,176✔
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)
454✔
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,
180✔
475
            ["loadFromString"] = false,
180✔
476
            ["ignoreQuotes"] = false,
180✔
477
            ["bufferSize"] = 2^16
180✔
478
        }
180✔
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,132✔
534
        fieldsToKeep = nil,
2,132✔
535
        inputLength = endOfHeaderRow,
3,198✔
536
        buffered = false,
2,132✔
537
        ignoreQuotes = options.ignoreQuotes,
3,198✔
538
        rowOffset = 0
2,132✔
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
-- runs the show!
561
function ftcsv.parse(inputFile, delimiter, options)
30✔
562
    local delimiter, options = determineArgumentOrder(delimiter, options)
3,060✔
563

564
    local options, fieldsToKeep = parseOptions(delimiter, options, false)
3,060✔
565

566
    local inputString = initializeInputFromStringOrFile(inputFile, options, "*all")
3,048✔
567

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

570
    local output = parseString(inputString, endOfHeaders, parserArgs)
3,024✔
571

572
    return output, finalHeaders
3,000✔
573
end
574

575
local function getFileSize (file)
576
    local current = file:seek()
186✔
577
    local size = file:seek("end")
186✔
578
    file:seek("set", current)
186✔
579
    return size
186✔
580
end
581

582
local function determineAtEndOfFile(file, fileSize)
583
    if file:seek() >= fileSize then
318✔
584
        return true
138✔
585
    else
586
        return false
180✔
587
    end
588
end
589

590
local function initializeInputFile(inputString, options)
591
    if options.loadFromString == true then
192✔
592
        error("ftcsv: parseLine currently doesn't support loading from string")
6✔
593
    end
594
    return initializeInputFromStringOrFile(inputString, options, options.bufferSize)
186✔
595
end
596

597
function ftcsv.parseLine(inputFile, delimiter, userOptions)
30✔
598
    local delimiter, userOptions = determineArgumentOrder(delimiter, userOptions)
192✔
599
    local options, fieldsToKeep = parseOptions(delimiter, userOptions, true)
192✔
600
    local inputString, file = initializeInputFile(inputFile, options)
192✔
601

602

603
    local fileSize, atEndOfFile = 0, false
186✔
604
    fileSize = getFileSize(file)
248✔
605
    atEndOfFile = determineAtEndOfFile(file, fileSize)
248✔
606

607
    local endOfHeaders, parserArgs, _ = parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, atEndOfFile)
186✔
608
    parserArgs.buffered = true
162✔
609
    parserArgs.endOfFile = atEndOfFile
162✔
610

611
    local parsedBuffer, endOfParsedInput, totalColumnCount = parseString(inputString, endOfHeaders, parserArgs)
162✔
612
    parserArgs.totalColumnCount = totalColumnCount
162✔
613

614
    inputString = ssub(inputString, endOfParsedInput)
216✔
615
    local bufferIndex, returnedRowsCount = 0, 0
162✔
616
    local currentRow, buffer
617

618
    return function()
619
        -- check parsed buffer for value
620
        bufferIndex = bufferIndex + 1
552✔
621
        currentRow = parsedBuffer[bufferIndex]
552✔
622
        if currentRow then
552✔
623
            returnedRowsCount = returnedRowsCount + 1
282✔
624
            return returnedRowsCount, currentRow
282✔
625
        end
626

627
        -- read more of the input
628
        buffer = file:read(options.bufferSize)
270✔
629
        if not buffer then
270✔
630
            file:close()
138✔
631
            return nil
138✔
632
        else
633
            parserArgs.endOfFile = determineAtEndOfFile(file, fileSize)
176✔
634
        end
635

636
        -- appends the new input to what was left over
637
        inputString = inputString .. buffer
132✔
638

639
        -- re-analyze and load buffer
640
        parserArgs.rowOffset = returnedRowsCount
132✔
641
        parsedBuffer, endOfParsedInput = parseString(inputString, 1, parserArgs)
176✔
642
        bufferIndex = 1
132✔
643

644
        -- cut the input string down
645
        inputString = ssub(inputString, endOfParsedInput)
176✔
646

647
        if #parsedBuffer == 0 then
132✔
648
            error("ftcsv: bufferSize needs to be larger to parse this file")
24✔
649
        end
650

651
        returnedRowsCount = returnedRowsCount + 1
108✔
652
        return returnedRowsCount, parsedBuffer[bufferIndex]
108✔
653
    end
654
end
655

656

657

658
-- The ENCODER code is below here
659
-- This could be broken out, but is kept here for portability
660

661
local function generateCustomToString(valueToConvertNilTo)
662
    local newReturnValue = tostring(valueToConvertNilTo)
36✔
663
    local generatedFunction = function(field)
664
        if type(field) == "nil" then
324✔
665
            return newReturnValue
108✔
666
        else
667
            return tostring(field)
216✔
668
        end
669
    end
670
    return generatedFunction
36✔
671
end
672

673
local function generateDelimitField(customToString)
674
    local delimitField = function(field)
675
        field = customToString(field)
3,600✔
676
        if field:find('"') then
3,564✔
677
            return field:gsub('"', '""')
210✔
678
        else
679
            return field
3,354✔
680
        end
681
    end
682
    return delimitField
948✔
683
end
684

685
local function generateDelimitAndQuoteField(delimiter, customToString)
686
    local generatedFunction = function(field)
687
        field = customToString(field)
1,500✔
688
        if field:find('"') then
1,428✔
689
            return '"' .. field:gsub('"', '""') .. '"'
78✔
690
        elseif field:find('[\n' .. delimiter .. ']') then
1,350✔
691
            return '"' .. field .. '"'
72✔
692
        else
693
            return field
1,278✔
694
        end
695
    end
696
    return generatedFunction
312✔
697
end
698

699
local function escapeHeadersForLuaGenerator(headers)
700
    local escapedHeaders = {}
552✔
701
    for i = 1, #headers do
2,250✔
702
        if headers[i]:find('"') then
1,698✔
703
            escapedHeaders[i] = headers[i]:gsub('"', '\\"')
48✔
704
        else
705
            escapedHeaders[i] = headers[i]
1,650✔
706
        end
707
    end
708
    return escapedHeaders
552✔
709
end
710

711
-- a function that compiles some lua code to quickly print out the csv
712
local function csvLineGenerator(inputTable, delimiter, headers, options)
713
    local escapedHeaders = escapeHeadersForLuaGenerator(headers)
552✔
714

715
    local outputFunc = [[
716
        local args, i = ...
717
        i = i + 1;
718
        if i > ]] .. #inputTable .. [[ then return nil end;
552✔
719
        return i, '"' .. args.delimitField(args.t[i]["]] ..
552✔
720
            table.concat(escapedHeaders, [["]) .. '"]] ..
1,104✔
721
            delimiter .. [["' .. args.delimitField(args.t[i]["]]) ..
828✔
722
            [["]) .. '"\r\n']]
552✔
723

724
    if options and options.onlyRequiredQuotes == true then
552✔
725
        outputFunc = [[
726
            local args, i = ...
727
            i = i + 1;
728
            if i > ]] .. #inputTable .. [[ then return nil end;
156✔
729
            return i, args.delimitField(args.t[i]["]] ..
156✔
730
                table.concat(escapedHeaders, [["]) .. ']] ..
312✔
731
                delimiter .. [[' .. args.delimitField(args.t[i]["]]) ..
234✔
732
                [["]) .. '\r\n']]
182✔
733
    end
734

735
    local arguments = {}
552✔
736
    arguments.t = inputTable
552✔
737
    -- we want to use the same delimitField throughout,
738
    -- so we're just going to pass it in
739

740
    local toStringToUse = tostring
552✔
741
    if options and options.encodeNilAs ~= nil then
552✔
742
        toStringToUse = generateCustomToString(options.encodeNilAs)
48✔
743
    end
744
    if options and options.onlyRequiredQuotes == true then
552✔
745
        arguments.delimitField = generateDelimitAndQuoteField(delimiter, toStringToUse)
208✔
746
    else
747
        arguments.delimitField = generateDelimitField(toStringToUse)
528✔
748
    end
749

750
    return luaCompatibility.load(outputFunc), arguments, 0
552✔
751

752
end
753

754
local function validateHeaders(headers, inputTable)
755
    for i = 1, #headers do
1,170✔
756
        if inputTable[1][headers[i]] == nil then
882✔
757
            error("ftcsv: the field '" .. headers[i] .. "' doesn't exist in the inputTable")
6✔
758
        end
759
    end
760
end
761

762
local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
763
    local output = {}
552✔
764
    if options and options.onlyRequiredQuotes == true then
552✔
765
        output[1] = table.concat(escapedHeaders, delimiter) .. '\r\n'
156✔
766
    else
767
        output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n'
396✔
768
    end
769
    return output
552✔
770
end
771

772
local function escapeHeadersForOutput(headers, delimiter, options)
773
    local escapedHeaders = {}
552✔
774
    local delimitField = generateDelimitField(tostring)
552✔
775
    if options and options.onlyRequiredQuotes == true then
552✔
776
        delimitField = generateDelimitAndQuoteField(delimiter, tostring)
208✔
777
    end
778
    for i = 1, #headers do
2,250✔
779
        escapedHeaders[i] = delimitField(headers[i])
2,264✔
780
    end
781

782
    return escapedHeaders
552✔
783
end
784

785
local function extractHeadersFromTable(inputTable)
786
    local headers = {}
534✔
787
    for key, _ in pairs(inputTable[1]) do
2,184✔
788
        headers[#headers+1] = key
1,650✔
789
    end
790

791
    -- lets make the headers alphabetical
792
    table.sort(headers)
534✔
793

794
    return headers
534✔
795
end
796

797
local function getHeadersFromOptions(options)
798
    local headers = nil
465✔
799
    if options then
558✔
800
        if options.fieldsToKeep ~= nil then
300✔
801
            assert(
48✔
802
                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✔
803
            headers = options.fieldsToKeep
24✔
804
        end
805
    end
806
    return headers
558✔
807
end
808

809
local function initializeGenerator(inputTable, delimiter, options)
810
    -- delimiter MUST be one character
811
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
558✔
812

813
    local headers = getHeadersFromOptions(options)
558✔
814
    if headers == nil then
558✔
815
        headers = extractHeadersFromTable(inputTable)
712✔
816
    end
817
    if options and options.allowMissingKeys == nil then
558✔
818
        validateHeaders(headers, inputTable)
294✔
819
    end
820

821
    local escapedHeaders = escapeHeadersForOutput(headers, delimiter, options)
552✔
822
    local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
552✔
823
    return output, headers
552✔
824
end
825

826
-- works really quickly with luajit-2.1, because table.concat life
827
function ftcsv.encode(inputTable, delimiter, options)
30✔
828
    local delimiter, options = determineArgumentOrder(delimiter, options)
558✔
829
    local output, headers = initializeGenerator(inputTable, delimiter, options)
558✔
830

831
    for i, line in csvLineGenerator(inputTable, delimiter, headers, options) do
2,296✔
832
        output[i+1] = line
1,032✔
833
    end
834

835
    -- combine and return final string
836
    return table.concat(output)
552✔
837
end
838

839
return ftcsv
30✔
840

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

© 2025 Coveralls, Inc