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

FourierTransformer / ftcsv / 10390854800

14 Aug 2024 03:38PM UTC coverage: 98.841% (+0.09%) from 98.755%
10390854800

push

github

web-flow
Make the delimiter optional in the encoder (#45)

17 of 17 new or added lines in 3 files covered. (100.0%)

17 existing lines in 1 file now uncovered.

1364 of 1380 relevant lines covered (98.84%)

1643.45 hits per line

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

99.5
/ftcsv.lua
1
local ftcsv = {
30✔
2
    _VERSION = 'ftcsv 1.4.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-2023 Shakil Thakur
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.
UNCOV
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
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
10✔
49
    -- finds the end of an escape sequence
50
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
10✔
51
        local currentChar, nextChar = sbyte(inputString, i), nil
3,616✔
52
        while i <= inputLength do
23,112✔
53
            nextChar = sbyte(inputString, i+1)
23,082✔
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
23,082✔
58
                doubleQuoteEscape = true
558✔
59
                i = i + 2
558✔
60
                currentChar = sbyte(inputString, i)
558✔
61

62
            -- identifies the escape toggle
63
            elseif currentChar == quote and nextChar ~= quote then
22,524✔
64
                return i-1, doubleQuoteEscape
3,586✔
65
            else
66
                i = i + 1
18,938✔
67
                currentChar = nextChar
18,938✔
68
            end
69
        end
70
    end
71

72
else
UNCOV
73
    luaCompatibility.LuaJIT = false
20✔
74

75
    -- vanilla lua closing quote finder
UNCOV
76
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
20✔
77
        local j, difference
UNCOV
78
        i, j = inputString:find('"+', i)
7,988✔
UNCOV
79
        if j == nil then
7,988✔
UNCOV
80
            return nil
60✔
81
        end
UNCOV
82
        difference = j - i
7,928✔
UNCOV
83
        if difference >= 1 then doubleQuoteEscape = true end
7,928✔
UNCOV
84
        if difference % 2 == 1 then
7,928✔
UNCOV
85
            return luaCompatibility.findClosingQuote(j+1, inputLength, inputString, quote, doubleQuoteEscape)
756✔
86
        end
UNCOV
87
        return j-1, doubleQuoteEscape
7,172✔
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,180✔
95
    local headerSet = {}
3,180✔
96
    for i = 1, #headerField do
12,846✔
97
        if not headerSet[headerField[i]] then
9,666✔
98
            if fieldsToKeep ~= nil and fieldsToKeep[headerField[i]] then
9,360✔
99
                table.insert(realHeaders, headerField[i])
1,800✔
100
                headerSet[headerField[i]] = true
1,800✔
101
            elseif fieldsToKeep == nil then
7,560✔
102
                table.insert(realHeaders, headerField[i])
6,810✔
103
                headerSet[headerField[i]] = true
6,810✔
104
            end
105
        end
106
    end
107
    return realHeaders
3,180✔
108
end
109

110

111
local function determineTotalColumnCount(headerField, fieldsToKeep)
112
    local totalColumnCount = 0
6,504✔
113
    local headerFieldSet = {}
6,504✔
114
    for _, header in pairs(headerField) do
16,830✔
115
        -- count unique columns and
116
        -- also figure out if it's a field to keep
117
        if not headerFieldSet[header] and
10,326✔
118
            (fieldsToKeep == nil or fieldsToKeep[header]) then
10,020✔
119
            headerFieldSet[header] = true
9,270✔
120
            totalColumnCount = totalColumnCount + 1
9,270✔
121
        end
122
    end
123
    return totalColumnCount
6,504✔
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,742✔
131
        if headers:find("]") then
6,402✔
132
            return nil
6✔
133
        end
134
    end
135
    local rawSetup = "local t, k, _ = ... \
136
    rawset(t, k, {[ [[%s]] ]=true})"
2,340✔
137
    rawSetup = rawSetup:format(table.concat(finalHeaders, "]] ]=true, [ [["))
2,340✔
138
    return luaCompatibility.load(rawSetup)
2,340✔
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,504✔
146
    local currentChar, nextChar = sbyte(inputString, i), nil
6,504✔
147
    local skipChar = 0
6,504✔
148
    local field
149
    local fieldStart = i
6,504✔
150
    local fieldNum = 1
6,504✔
151
    local lineNum = 1
6,504✔
152
    local lineStart = i
6,504✔
153
    local doubleQuoteEscape, emptyIdentified = false, false
6,504✔
154

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

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

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

172
    local outResults = {}
6,504✔
173

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

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

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

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

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

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

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

221
        -- empty string
222
        if ignoreQuotes == false and currentChar == quote and nextChar == quote then
63,132✔
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,436✔
231
            fieldStart = i + 1
10,848✔
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,848✔
236
                fieldStart = fieldStart - 2
246✔
237
                emptyIdentified = false
246✔
238
            end
239
            skipChar = 1
10,848✔
240
            i, doubleQuoteEscape = luaCompatibility.findClosingQuote(i+1, inputLength, inputString, quote, doubleQuoteEscape)
14,464✔
241

242
        -- create some fields
243
        elseif currentChar == delimiterByte then
51,588✔
244
            assignValueToField()
16,746✔
245

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

250
        -- newline
251
        elseif (currentChar == LF or currentChar == CR) then
34,842✔
252
            assignValueToField()
6,240✔
253

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

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

278
        elseif luaCompatibility.LuaJIT == false then
28,602✔
UNCOV
279
            skipIndex = inputString:find(charPatternToSkip, i)
11,392✔
UNCOV
280
            if skipIndex then
11,392✔
UNCOV
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,126✔
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,036✔
300
        if (skipChar > 0) then
63,036✔
301
            currentChar = sbyte(inputString, i)
19,076✔
302
        else
303
            currentChar = nextChar
43,960✔
304
        end
305
        skipChar = 0
63,036✔
306
    end
307

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

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

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

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

331
local function handleHeaders(headerField, options)
332
    -- for files where there aren't headers!
333
    if options.headers == false then
3,192✔
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,534✔
340
            if #headerName == 0 then
7,188✔
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,180✔
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,180✔
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,180✔
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,228✔
387
        inputString = inputFile
2,886✔
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,222✔
394
        error('ftcsv: Cannot parse an empty file')
6✔
395
    end
396
    return inputString, file
3,216✔
397
end
398

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

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

409
    -- if nothing is specified, assume "," delimited and call it a day!
410
    else
411
        return ",", nil
114✔
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,246✔
418

419
    local fieldsToKeep = nil
3,246✔
420

421
    if options then
3,246✔
422

423
    if options.headers ~= nil then
2,976✔
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,976✔
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,976✔
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,970✔
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,898✔
447
    end
448

449
    if options.headerFunc ~= nil then
2,970✔
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,970✔
454
        options.ignoreQuotes = false
2,862✔
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,970✔
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,892✔
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✔
UNCOV
477
            ["bufferSize"] = 2^16
180✔
478
        }
180✔
479
    end
480

481
    return options, fieldsToKeep
3,234✔
482

483
end
484

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

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

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

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

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

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

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

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

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

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

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

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

560
-- runs the show!
561
function ftcsv.parse(inputFile, delimiter, options)
30✔
562
    local delimiter, options = determineArgumentOrder(delimiter, options)
3,054✔
563

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

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

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

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

572
    return output, finalHeaders
2,994✔
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

662
local function delimitField(field)
663
    field = tostring(field)
3,252✔
664
    if field:find('"') then
3,252✔
665
        return field:gsub('"', '""')
210✔
666
    else
667
        return field
3,042✔
668
    end
669
end
670

671
local function generateDelimitAndQuoteField(delimiter)
672
    local generatedFunction = function(field)
673
        field = tostring(field)
1,140✔
674
        if field:find('"') then
1,140✔
675
            return '"' .. field:gsub('"', '""') .. '"'
78✔
676
        elseif field:find('[\n' .. delimiter .. ']') then
1,062✔
677
            return '"' .. field .. '"'
36✔
678
        else
679
            return field
1,026✔
680
        end
681
    end
682
    return generatedFunction
264✔
683
end
684

685
local function escapeHeadersForLuaGenerator(headers)
686
    local escapedHeaders = {}
504✔
687
    for i = 1, #headers do
2,052✔
688
        if headers[i]:find('"') then
1,548✔
689
            escapedHeaders[i] = headers[i]:gsub('"', '\\"')
48✔
690
        else
691
            escapedHeaders[i] = headers[i]
1,500✔
692
        end
693
    end
694
    return escapedHeaders
504✔
695
end
696

697
-- a function that compiles some lua code to quickly print out the csv
698
local function csvLineGenerator(inputTable, delimiter, headers, options)
699
    local escapedHeaders = escapeHeadersForLuaGenerator(headers)
504✔
700

701
    local outputFunc = [[
702
        local args, i = ...
703
        i = i + 1;
704
        if i > ]] .. #inputTable .. [[ then return nil end;
504✔
705
        return i, '"' .. args.delimitField(args.t[i]["]] ..
504✔
706
            table.concat(escapedHeaders, [["]) .. '"]] ..
1,008✔
707
            delimiter .. [["' .. args.delimitField(args.t[i]["]]) ..
756✔
708
            [["]) .. '"\r\n']]
504✔
709

710
    if options and options.onlyRequiredQuotes == true then
504✔
711
        outputFunc = [[
712
            local args, i = ...
713
            i = i + 1;
714
            if i > ]] .. #inputTable .. [[ then return nil end;
132✔
715
            return i, args.delimitField(args.t[i]["]] ..
132✔
716
                table.concat(escapedHeaders, [["]) .. ']] ..
264✔
717
                delimiter .. [[' .. args.delimitField(args.t[i]["]]) ..
198✔
718
                [["]) .. '\r\n']]
154✔
719
    end
720

721
    local arguments = {}
504✔
722
    arguments.t = inputTable
504✔
723
    -- we want to use the same delimitField throughout,
724
    -- so we're just going to pass it in
725
    if options and options.onlyRequiredQuotes == true then
504✔
726
        arguments.delimitField = generateDelimitAndQuoteField(delimiter)
176✔
727
    else
728
        arguments.delimitField = delimitField
372✔
729
    end
730

731
    return luaCompatibility.load(outputFunc), arguments, 0
504✔
732

733
end
734

735
local function validateHeaders(headers, inputTable)
736
    for i = 1, #headers do
2,058✔
737
        if inputTable[1][headers[i]] == nil then
1,554✔
738
            error("ftcsv: the field '" .. headers[i] .. "' doesn't exist in the inputTable")
6✔
739
        end
740
    end
741
end
742

743
local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
744
    local output = {}
504✔
745
    if options and options.onlyRequiredQuotes == true then
504✔
746
        output[1] = table.concat(escapedHeaders, delimiter) .. '\r\n'
132✔
747
    else
748
        output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n'
372✔
749
    end
750
    return output
504✔
751
end
752

753
local function escapeHeadersForOutput(headers, delimiter, options)
754
    local escapedHeaders = {}
504✔
755
    local delimitField = delimitField
504✔
756
    if options and options.onlyRequiredQuotes == true then
504✔
757
        delimitField = generateDelimitAndQuoteField(delimiter)
176✔
758
    end
759
    for i = 1, #headers do
2,052✔
760
        escapedHeaders[i] = delimitField(headers[i])
2,064✔
761
    end
762

763
    return escapedHeaders
504✔
764
end
765

766
local function extractHeadersFromTable(inputTable)
767
    local headers = {}
492✔
768
    for key, _ in pairs(inputTable[1]) do
2,016✔
769
        headers[#headers+1] = key
1,524✔
770
    end
771

772
    -- lets make the headers alphabetical
773
    table.sort(headers)
492✔
774

775
    return headers
492✔
776
end
777

778
local function getHeadersFromOptions(options)
779
    local headers = nil
425✔
780
    if options then
510✔
781
        if options.fieldsToKeep ~= nil then
258✔
782
            assert(
36✔
783
                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) .. "'.")
18✔
784
            headers = options.fieldsToKeep
18✔
785
        end
786
    end
787
    return headers
510✔
788
end
789

790
local function initializeGenerator(inputTable, delimiter, options)
791
    -- delimiter MUST be one character
792
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
510✔
793

794
    local headers = getHeadersFromOptions(options)
510✔
795
    if headers == nil then
510✔
796
        headers = extractHeadersFromTable(inputTable)
656✔
797
    end
798
    validateHeaders(headers, inputTable)
510✔
799

800
    local escapedHeaders = escapeHeadersForOutput(headers, delimiter, options)
504✔
801
    local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options)
504✔
802
    return output, headers
504✔
803
end
804

805
-- works really quickly with luajit-2.1, because table.concat life
806
function ftcsv.encode(inputTable, delimiter, options)
30✔
807
    local delimiter, options = determineArgumentOrder(delimiter, options)
510✔
808
    local output, headers = initializeGenerator(inputTable, delimiter, options)
510✔
809

810
    for i, line in csvLineGenerator(inputTable, delimiter, headers, options) do
2,024✔
811
        output[i+1] = line
888✔
812
    end
813

814
    -- combine and return final string
815
    return table.concat(output)
504✔
816
end
817

818
return ftcsv
30✔
819

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