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

FourierTransformer / ftcsv / 3834624305

pending completion
3834624305

push

github

GitHub
Delete .travis.yml

1064 of 1091 relevant lines covered (97.53%)

349.45 hits per line

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

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

8
        Copyright (c) 2016-2020 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.
27
    ]]
×
28
}
29

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

34
-- luajit/lua compatability layer
35
local luaCompatibility = {}
5✔
36
if type(jit) == 'table' or _ENV then
5✔
37
    -- luajit and lua 5.2+
38
    luaCompatibility.load = _G.load
5✔
39
else
40
    -- lua 5.1
41
    luaCompatibility.load = loadstring
×
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
5✔
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,440✔
52
        while i <= inputLength do
8,981✔
53
            nextChar = sbyte(inputString, i+1)
8,973✔
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
8,973✔
58
                doubleQuoteEscape = true
170✔
59
                i = i + 2
170✔
60
                currentChar = sbyte(inputString, i)
170✔
61

62
            -- identifies the escape toggle
63
            elseif currentChar == quote and nextChar ~= quote then
8,803✔
64
                return i-1, doubleQuoteEscape
1,432✔
65
            else
66
                i = i + 1
7,371✔
67
                currentChar = nextChar
7,371✔
68
            end
69
        end
70
    end
71

72
else
73
    luaCompatibility.LuaJIT = false
×
74

75
    -- vanilla lua closing quote finder
76
    function luaCompatibility.findClosingQuote(i, inputLength, inputString, quote, doubleQuoteEscape)
×
77
        local j, difference
78
        i, j = inputString:find('"+', i)
×
79
        if j == nil then
×
80
            return nil
×
81
        end
82
        difference = j - i
×
83
        if difference >= 1 then doubleQuoteEscape = true end
×
84
        if difference % 2 == 1 then
×
85
            return luaCompatibility.findClosingQuote(j+1, inputLength, inputString, quote, doubleQuoteEscape)
×
86
        end
87
        return j-1, doubleQuoteEscape
×
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 = {}
454✔
95
    local headerSet = {}
454✔
96
    for i = 1, #headerField do
1,826✔
97
        if not headerSet[headerField[i]] then
1,372✔
98
            if fieldsToKeep ~= nil and fieldsToKeep[headerField[i]] then
1,321✔
99
                table.insert(realHeaders, headerField[i])
298✔
100
                headerSet[headerField[i]] = true
298✔
101
            elseif fieldsToKeep == nil then
1,023✔
102
                table.insert(realHeaders, headerField[i])
899✔
103
                headerSet[headerField[i]] = true
899✔
104
            end
105
        end
106
    end
107
    return realHeaders
454✔
108
end
109

110

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

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

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

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

172
    local outResults = {}
921✔
173

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

182
    if headersMetamethod then
921✔
183
        setmetatable(outResults, {__newindex = headersMetamethod})
334✔
184
    end
185
    outResults[1] = {}
921✔
186

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

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

193
            -- create new field
194
            if ignoreQuotes == false and sbyte(inputString, i-1) == quote then
3,841✔
195
                field = ssub(inputString, fieldStart, i-2)
2,768✔
196
            else
197
                field = ssub(inputString, fieldStart, i-1)
4,914✔
198
            end
199
            if doubleQuoteEscape then
3,841✔
200
                field = field:gsub('""', '"')
91✔
201
            end
202

203
            -- reset flags
204
            doubleQuoteEscape = false
3,841✔
205
            emptyIdentified = false
3,841✔
206

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

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

221
        -- empty string
222
        if ignoreQuotes == false and currentChar == quote and nextChar == quote then
12,070✔
223
            skipChar = 1
84✔
224
            fieldStart = i + 2
84✔
225
            emptyIdentified = true
84✔
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
11,986✔
231
            fieldStart = i + 1
1,440✔
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
1,440✔
236
                fieldStart = fieldStart - 2
41✔
237
                emptyIdentified = false
41✔
238
            end
239
            skipChar = 1
1,440✔
240
            i, doubleQuoteEscape = luaCompatibility.findClosingQuote(i+1, inputLength, inputString, quote, doubleQuoteEscape)
2,880✔
241

242
        -- create some fields
243
        elseif currentChar == delimiterByte then
10,546✔
244
            assignValueToField()
2,268✔
245

246
            -- increaseFieldIndices
247
            fieldNum = fieldNum + 1
2,268✔
248
            fieldStart = i + 1
2,268✔
249

250
        -- newline
251
        elseif (currentChar == LF or currentChar == CR) then
8,278✔
252
            assignValueToField()
841✔
253

254
            -- handle CRLF
255
            if (currentChar == CR and nextChar == LF) then
841✔
256
                skipChar = 1
295✔
257
                fieldStart = fieldStart + 1
295✔
258
            end
259

260
            -- incrememnt for new line
261
            if fieldNum < totalColumnCount then
841✔
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
1✔
265
                    fieldStart = i + 1 + skipChar
×
266
                    lineStart = fieldStart
×
267
                else
268
                    error('ftcsv: too few columns in row ' .. options.rowOffset + lineNum)
1✔
269
                end
270
            else
271
                lineNum = lineNum + 1
840✔
272
                outResults[lineNum] = {}
840✔
273
                fieldNum = 1
840✔
274
                fieldStart = i + 1 + skipChar
840✔
275
                lineStart = fieldStart
840✔
276
            end
277

278
        elseif luaCompatibility.LuaJIT == false then
7,437✔
279
            skipIndex = inputString:find(charPatternToSkip, i)
×
280
            if skipIndex then
×
281
                skipChar = skipIndex - i - 1
×
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
12,069✔
289
            if buffered then
8✔
290
                outResults[lineNum] = nil
7✔
291
                return outResults, lineStart
7✔
292
            else
293
                error("ftcsv: can't find closing quote in row " .. options.rowOffset + lineNum ..
2✔
294
                 ". Try running with the option ignoreQuotes=true if the source incorrectly uses quotes.")
1✔
295
            end
296
        end
297

298
        -- Increment Counter
299
        i = i + 1 + skipChar
12,061✔
300
        if (skipChar > 0) then
12,061✔
301
            currentChar = sbyte(inputString, i)
1,811✔
302
        else
303
            currentChar = nextChar
10,250✔
304
        end
305
        skipChar = 0
12,061✔
306
    end
307

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

313
    -- create last new field
314
    assignValueToField()
906✔
315

316
    -- remove last field if empty
317
    if fieldNum < totalColumnCount then
905✔
318

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

328
    return outResults, i, totalColumnCount
904✔
329
end
330

331
local function handleHeaders(headerField, options)
332
    -- make sure a header isn't empty
333
    for _, headerName in ipairs(headerField) do
1,830✔
334
        if #headerName == 0 then
1,376✔
335
            error('ftcsv: Cannot parse a file which contains empty headers')
2✔
336
        end
337
    end
338

339
    -- for files where there aren't headers!
340
    if options.headers == false then
454✔
341
        for j = 1, #headerField do
520✔
342
            headerField[j] = j
390✔
343
        end
344
    end
345

346
    -- rename fields as needed!
347
    if options.rename then
454✔
348
        -- basic rename (["a" = "apple"])
349
        for j = 1, #headerField do
814✔
350
            if options.rename[headerField[j]] then
611✔
351
                headerField[j] = options.rename[headerField[j]]
432✔
352
            end
353
        end
354
        -- files without headers, but with a options.rename need to be handled too!
355
        if #options.rename > 0 then
203✔
356
            for j = 1, #options.rename do
275✔
357
                headerField[j] = options.rename[j]
200✔
358
            end
359
        end
360
    end
361

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

369
    return headerField
454✔
370
end
371

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

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

399
local function parseOptions(delimiter, options, fromParseLine)
400
    -- delimiter MUST be one character
401
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
463✔
402

403
    local fieldsToKeep = nil
463✔
404

405
    if options then
463✔
406

407
        if options.headers ~= nil then
418✔
408
            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) .. "'.")
131✔
409
        end
410

411
        if options.rename ~= nil then
418✔
412
            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) .. "'.")
203✔
413
        end
414

415
        if options.fieldsToKeep ~= nil then
418✔
416
            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) .. "'.")
150✔
417
            local ofieldsToKeep = options.fieldsToKeep
150✔
418
            if ofieldsToKeep ~= nil then
150✔
419
                fieldsToKeep = {}
150✔
420
                for j = 1, #ofieldsToKeep do
450✔
421
                    fieldsToKeep[ofieldsToKeep[j]] = true
300✔
422
                end
423
            end
424
            if options.headers == false and options.rename == nil then
150✔
425
                error("ftcsv: fieldsToKeep only works with header-less files when using the 'rename' functionality")
1✔
426
            end
427
        end
428

429
        if options.loadFromString ~= nil then
417✔
430
            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) .. "'.")
411✔
431
        end
432

433
        if options.headerFunc ~= nil then
417✔
434
            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) .. "'.")
49✔
435
        end
436

437
        if options.ignoreQuotes == nil then
417✔
438
            options.ignoreQuotes = false
404✔
439
        else
440
            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) .. "'.")
13✔
441
        end
442

443
        if options.bufferSize == nil then
417✔
444
            options.bufferSize = 2^16
411✔
445
        else
446
            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) .. "'.")
6✔
447
            if fromParseLine == false then
6✔
448
                error("ftcsv: bufferSize can only be specified using 'parseLine'. When using 'parse', the entire file is read into memory")
1✔
449
            end
450
        end
451

452
    else
453
        options = {
45✔
454
            ["headers"] = true,
455
            ["loadFromString"] = false,
456
            ["ignoreQuotes"] = false,
457
            ["bufferSize"] = 2^16
×
458
        }
459
    end
460

461
    return options, fieldsToKeep
461✔
462

463
end
464

465
local function findEndOfHeaders(str, entireFile)
466
    local i = 1
458✔
467
    local quote = sbyte('"')
458✔
468
    local newlines = {
458✔
469
        [sbyte("\n")] = true,
458✔
470
        [sbyte("\r")] = true
458✔
471
    }
472
    local quoted = false
458✔
473
    local char = sbyte(str, i)
458✔
474
    repeat
475
        -- this should still work for escaped quotes
476
        -- ex: " a "" b \r\n " -- there is always a pair around the newline
477
        if char == quote then
6,347✔
478
            quoted = not quoted
1,222✔
479
        end
480
        i = i + 1
6,347✔
481
        char = sbyte(str, i)
6,347✔
482
    until (newlines[char] and not quoted) or char == nil
6,347✔
483

484
    if not entireFile and char == nil then
458✔
485
        error("ftcsv: bufferSize needs to be larger to parse this file")
2✔
486
    end
487

488
    local nextChar = sbyte(str, i+1)
456✔
489
    if nextChar == sbyte("\n") and char == sbyte("\r") then
456✔
490
        i = i + 1
149✔
491
    end
492
    return i
456✔
493
end
494

495
local function determineBOMOffset(inputString)
496
    -- BOM files start with bytes 239, 187, 191
497
    if sbyte(inputString, 1) == 239
458✔
498
        and sbyte(inputString, 2) == 187
171✔
499
        and sbyte(inputString, 3) == 191 then
171✔
500
        return 4
171✔
501
    else
502
        return 1
287✔
503
    end
504
end
505

506
local function parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, entireFile)
507
    local startLine = determineBOMOffset(inputString)
458✔
508

509
    local endOfHeaderRow = findEndOfHeaders(inputString, entireFile)
458✔
510

511
    local parserArgs = {
456✔
512
        delimiter = delimiter,
456✔
513
        headerField = nil,
514
        fieldsToKeep = nil,
515
        inputLength = endOfHeaderRow,
456✔
516
        buffered = false,
517
        ignoreQuotes = options.ignoreQuotes,
456✔
518
        rowOffset = 0
×
519
    }
520

521
    local rawHeaders, endOfHeaders = parseString(inputString, startLine, parserArgs)
456✔
522

523
    -- manipulate the headers as per the options
524
    local modifiedHeaders = handleHeaders(rawHeaders[1], options)
456✔
525
    parserArgs.headerField = modifiedHeaders
454✔
526
    parserArgs.fieldsToKeep = fieldsToKeep
454✔
527
    parserArgs.inputLength = nil
454✔
528

529
    if options.headers == false then endOfHeaders = startLine end
454✔
530

531
    local finalHeaders = determineRealHeaders(modifiedHeaders, fieldsToKeep)
454✔
532
    if options.headers ~= false then
454✔
533
        local headersMetamethod = generateHeadersMetamethod(finalHeaders)
324✔
534
        parserArgs.headersMetamethod = headersMetamethod
324✔
535
    end
536

537
    return endOfHeaders, parserArgs, finalHeaders
454✔
538
end
539

540
-- runs the show!
541
function ftcsv.parse(inputFile, delimiter, options)
5✔
542
    local options, fieldsToKeep = parseOptions(delimiter, options, false)
437✔
543

544
    local inputString = initializeInputFromStringOrFile(inputFile, options, "*all")
435✔
545

546
    local endOfHeaders, parserArgs, finalHeaders = parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, true)
433✔
547

548
    local output = parseString(inputString, endOfHeaders, parserArgs)
431✔
549

550
    return output, finalHeaders
427✔
551
end
552

553
local function getFileSize (file)
554
    local current = file:seek()
25✔
555
    local size = file:seek("end")
25✔
556
    file:seek("set", current)
25✔
557
    return size
25✔
558
end
559

560
local function determineAtEndOfFile(file, fileSize)
561
    if file:seek() >= fileSize then
36✔
562
        return true
21✔
563
    else
564
        return false
15✔
565
    end
566
end
567

568
local function initializeInputFile(inputString, options)
569
    if options.loadFromString == true then
26✔
570
        error("ftcsv: parseLine currently doesn't support loading from string")
1✔
571
    end
572
    return initializeInputFromStringOrFile(inputString, options, options.bufferSize)
25✔
573
end
574

575
function ftcsv.parseLine(inputFile, delimiter, userOptions)
5✔
576
    local options, fieldsToKeep = parseOptions(delimiter, userOptions, true)
26✔
577
    local inputString, file = initializeInputFile(inputFile, options)
26✔
578

579

580
    local fileSize, atEndOfFile = 0, false
25✔
581
    fileSize = getFileSize(file)
50✔
582
    atEndOfFile = determineAtEndOfFile(file, fileSize)
50✔
583

584
    local endOfHeaders, parserArgs, _ = parseHeadersAndSetupArgs(inputString, delimiter, options, fieldsToKeep, atEndOfFile)
25✔
585
    parserArgs.buffered = true
23✔
586
    parserArgs.endOfFile = atEndOfFile
23✔
587

588
    local parsedBuffer, endOfParsedInput, totalColumnCount = parseString(inputString, endOfHeaders, parserArgs)
23✔
589
    parserArgs.totalColumnCount = totalColumnCount
23✔
590

591
    inputString = ssub(inputString, endOfParsedInput)
46✔
592
    local bufferIndex, returnedRowsCount = 0, 0
23✔
593
    local currentRow, buffer
594

595
    return function()
596
        -- check parsed buffer for value
597
        bufferIndex = bufferIndex + 1
73✔
598
        currentRow = parsedBuffer[bufferIndex]
73✔
599
        if currentRow then
73✔
600
            returnedRowsCount = returnedRowsCount + 1
41✔
601
            return returnedRowsCount, currentRow
41✔
602
        end
603

604
        -- read more of the input
605
        buffer = file:read(options.bufferSize)
32✔
606
        if not buffer then
32✔
607
            file:close()
21✔
608
            return nil
21✔
609
        else
610
            parserArgs.endOfFile = determineAtEndOfFile(file, fileSize)
22✔
611
        end
612

613
        -- appends the new input to what was left over
614
        inputString = inputString .. buffer
11✔
615

616
        -- re-analyze and load buffer
617
        parserArgs.rowOffset = returnedRowsCount
11✔
618
        parsedBuffer, endOfParsedInput = parseString(inputString, 1, parserArgs)
22✔
619
        bufferIndex = 1
11✔
620

621
        -- cut the input string down
622
        inputString = ssub(inputString, endOfParsedInput)
22✔
623

624
        if #parsedBuffer == 0 then
11✔
625
            error("ftcsv: bufferSize needs to be larger to parse this file")
2✔
626
        end
627

628
        returnedRowsCount = returnedRowsCount + 1
9✔
629
        return returnedRowsCount, parsedBuffer[bufferIndex]
9✔
630
    end
631
end
632

633

634

635
-- The ENCODER code is below here
636
-- This could be broken out, but is kept here for portability
637

638

639
local function delimitField(field)
640
    field = tostring(field)
186✔
641
    if field:find('"') then
186✔
642
        return field:gsub('"', '""')
11✔
643
    else
644
        return field
175✔
645
    end
646
end
647

648
local function escapeHeadersForLuaGenerator(headers)
649
    local escapedHeaders = {}
22✔
650
    for i = 1, #headers do
88✔
651
        if headers[i]:find('"') then
66✔
652
            escapedHeaders[i] = headers[i]:gsub('"', '\\"')
2✔
653
        else
654
            escapedHeaders[i] = headers[i]
64✔
655
        end
656
    end
657
    return escapedHeaders
22✔
658
end
659

660
-- a function that compiles some lua code to quickly print out the csv
661
local function csvLineGenerator(inputTable, delimiter, headers)
662
    local escapedHeaders = escapeHeadersForLuaGenerator(headers)
22✔
663

664
    local outputFunc = [[
665
        local args, i = ...
666
        i = i + 1;
667
        if i > ]] .. #inputTable .. [[ then return nil end;
22✔
668
        return i, '"' .. args.delimitField(args.t[i]["]] ..
22✔
669
            table.concat(escapedHeaders, [["]) .. '"]] ..
44✔
670
            delimiter .. [["' .. args.delimitField(args.t[i]["]]) ..
22✔
671
            [["]) .. '"\r\n']]
22✔
672

673
    local arguments = {}
22✔
674
    arguments.t = inputTable
22✔
675
    -- we want to use the same delimitField throughout,
676
    -- so we're just going to pass it in
677
    arguments.delimitField = delimitField
22✔
678

679
    return luaCompatibility.load(outputFunc), arguments, 0
22✔
680

681
end
682

683
local function validateHeaders(headers, inputTable)
684
    for i = 1, #headers do
89✔
685
        if inputTable[1][headers[i]] == nil then
67✔
686
            error("ftcsv: the field '" .. headers[i] .. "' doesn't exist in the inputTable")
1✔
687
        end
688
    end
689
end
690

691
local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter)
692
    local output = {}
22✔
693
    output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n'
22✔
694
    return output
22✔
695
end
696

697
local function escapeHeadersForOutput(headers)
698
    local escapedHeaders = {}
22✔
699
    for i = 1, #headers do
88✔
700
        escapedHeaders[i] = delimitField(headers[i])
132✔
701
    end
702
    return escapedHeaders
22✔
703
end
704

705
local function extractHeadersFromTable(inputTable)
706
    local headers = {}
21✔
707
    for key, _ in pairs(inputTable[1]) do
85✔
708
        headers[#headers+1] = key
64✔
709
    end
710

711
    -- lets make the headers alphabetical
712
    table.sort(headers)
21✔
713

714
    return headers
21✔
715
end
716

717
local function getHeadersFromOptions(options)
718
    local headers = nil
23✔
719
    if options then
23✔
720
        if options.fieldsToKeep ~= nil then
2✔
721
            assert(
4✔
722
                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) .. "'.")
2✔
723
            headers = options.fieldsToKeep
2✔
724
        end
725
    end
726
    return headers
23✔
727
end
728

729
local function initializeGenerator(inputTable, delimiter, options)
730
    -- delimiter MUST be one character
731
    assert(#delimiter == 1 and type(delimiter) == "string", "the delimiter must be of string type and exactly one character")
23✔
732

733
    local headers = getHeadersFromOptions(options)
23✔
734
    if headers == nil then
23✔
735
        headers = extractHeadersFromTable(inputTable)
42✔
736
    end
737
    validateHeaders(headers, inputTable)
23✔
738

739
    local escapedHeaders = escapeHeadersForOutput(headers)
22✔
740
    local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter)
22✔
741
    return output, headers
22✔
742
end
743

744
-- works really quickly with luajit-2.1, because table.concat life
745
function ftcsv.encode(inputTable, delimiter, options)
5✔
746
    local output, headers = initializeGenerator(inputTable, delimiter, options)
23✔
747

748
    for i, line in csvLineGenerator(inputTable, delimiter, headers) do
142✔
749
        output[i+1] = line
38✔
750
    end
751

752
    -- combine and return final string
753
    return table.concat(output)
22✔
754
end
755

756
return ftcsv
5✔
757

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