summaryrefslogtreecommitdiffstats
path: root/cepis/csv.lua
diff options
context:
space:
mode:
Diffstat (limited to 'cepis/csv.lua')
-rw-r--r--cepis/csv.lua109
1 files changed, 109 insertions, 0 deletions
diff --git a/cepis/csv.lua b/cepis/csv.lua
new file mode 100644
index 0000000..be05c9a
--- /dev/null
+++ b/cepis/csv.lua
@@ -0,0 +1,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