summaryrefslogtreecommitdiffstats
path: root/cepis/csv.lua
blob: be05c9ac0207f5300bc1ddfc34c57d453fc5a53a (plain)
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