1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
|
--- A minimal RFC 4180 reader, for the batch endpoint.
--
-- The values the API sends may contain commas — a `logradouro` such as
-- `Rua Um, Dois` — and are then double-quoted with `""` escaping. Splitting on
-- `,` silently misaligns every column after the first such value, which is why
-- this exists instead of a `gmatch`.
--
-- @module cepis.csv
local M = {}
local BOM = "\239\187\191"
--- Parse CSV text into a list of rows, each a list of field strings.
--
-- Handles quoted fields, `""` escapes, embedded commas and newlines, both LF
-- and CRLF line endings, and a leading UTF-8 BOM. Blank lines yield no row.
-- Nothing is coerced: every field comes back as a string.
--
-- Text between a closing quote and the next delimiter is appended to the field
-- rather than starting a new one: malformed input must not silently gain a
-- column and shift every value after it.
--
-- @tparam string text the response body
-- @treturn table list of rows; each row is a list of strings
function M.parse(text)
if text:sub(1, 3) == BOM then
text = text:sub(4)
end
local rows = {}
local row = {}
local count = 0
local pos = 1
local size = #text
local function close_row()
if not (count == 1 and row[1] == "") then
rows[#rows + 1] = row
end
row = {}
count = 0
end
while pos <= size do
local field
if text:sub(pos, pos) == '"' then
local parts = {}
pos = pos + 1
while true do
local quote = text:find('"', pos, true)
if not quote then
parts[#parts + 1] = text:sub(pos)
pos = size + 1
break
end
parts[#parts + 1] = text:sub(pos, quote - 1)
if text:sub(quote + 1, quote + 1) == '"' then
parts[#parts + 1] = '"'
pos = quote + 2
else
pos = quote + 1
break
end
end
local trailing = text:find("[,\r\n]", pos)
if trailing ~= pos then
parts[#parts + 1] = text:sub(pos, (trailing or size + 1) - 1)
pos = trailing or size + 1
end
field = table.concat(parts)
else
local stop = text:find("[,\r\n]", pos)
if stop then
field = text:sub(pos, stop - 1)
pos = stop
else
field = text:sub(pos)
pos = size + 1
end
end
count = count + 1
row[count] = field
local char = text:sub(pos, pos)
if char == "," then
pos = pos + 1
if pos > size then
count = count + 1
row[count] = ""
close_row()
end
elseif char == "\r" or char == "\n" then
if char == "\r" and text:sub(pos + 1, pos + 1) == "\n" then
pos = pos + 2
else
pos = pos + 1
end
close_row()
else
close_row()
end
end
return rows
end
return M
|