708 lines
23 KiB
Elixir
708 lines
23 KiB
Elixir
defmodule NimbleCSV do
|
|||
|
|
@moduledoc ~S"""
|
||
|
|
NimbleCSV is a small and fast parsing and dumping library.
|
||
|
|
|
||
|
|
It works by building highly-inlined CSV parsers, designed
|
||
|
|
to work with strings, enumerables, and streams. At the top
|
||
|
|
of your file (and not inside a function), you can define your
|
||
|
|
own parser module:
|
||
|
|
|
||
|
|
NimbleCSV.define(MyParser, separator: "\t", escape: "\"")
|
||
|
|
|
||
|
|
Once defined, we can parse data accordingly:
|
||
|
|
|
||
|
|
iex> MyParser.parse_string("name\tage\njohn\t27")
|
||
|
|
[["john","27"]]
|
||
|
|
|
||
|
|
See the `define/2` function for the list of functions that
|
||
|
|
would be defined in `MyParser`.
|
||
|
|
|
||
|
|
## Parsing
|
||
|
|
|
||
|
|
NimbleCSV is by definition restricted in scope to do only
|
||
|
|
parsing (and dumping). The example above discarded the
|
||
|
|
headers when parsing the string, as NimbleCSV expects
|
||
|
|
developers to handle those explicitly later.
|
||
|
|
For example:
|
||
|
|
|
||
|
|
"name\tage\njohn\t27"
|
||
|
|
|> MyParser.parse_string()
|
||
|
|
|> Enum.map(fn [name, age] ->
|
||
|
|
%{name: name, age: String.to_integer(age)}
|
||
|
|
end)
|
||
|
|
|
||
|
|
This is particularly useful with the `c:parse_stream/1` functionality
|
||
|
|
that receives and returns a stream. For example, we can use it
|
||
|
|
to parse files line by line lazily:
|
||
|
|
|
||
|
|
"path/to/csv/file"
|
||
|
|
|> File.stream!(read_ahead: 100_000)
|
||
|
|
|> MyParser.parse_stream()
|
||
|
|
|> Stream.map(fn [name, age] ->
|
||
|
|
%{name: :binary.copy(name), age: String.to_integer(age)}
|
||
|
|
end)
|
||
|
|
|> Stream.run()
|
||
|
|
|
||
|
|
By default this library ships with two implementations:
|
||
|
|
|
||
|
|
* `NimbleCSV.RFC4180`, which is the most common implementation of
|
||
|
|
CSV parsing/dumping available using comma as separators and
|
||
|
|
double-quote as escape. If you want to use it in your codebase,
|
||
|
|
simply alias it to CSV and enjoy:
|
||
|
|
|
||
|
|
iex> alias NimbleCSV.RFC4180, as: CSV
|
||
|
|
iex> CSV.parse_string("name,age\njohn,27")
|
||
|
|
[["john","27"]]
|
||
|
|
|
||
|
|
* `NimbleCSV.Spreadsheet`, which uses UTF-16 and is most commonly
|
||
|
|
used by spreadsheet software, such as Excel, Numbers, etc.
|
||
|
|
|
||
|
|
### Binary references
|
||
|
|
|
||
|
|
One of the reasons behind NimbleCSV performance is that it performs
|
||
|
|
parsing by matching on binaries and extracting those fields as
|
||
|
|
binary references. Therefore, if you have a row such as:
|
||
|
|
|
||
|
|
one,two,three,four,five
|
||
|
|
|
||
|
|
NimbleCSV will return a list of `["one", "two", "three", "four", "five"]`
|
||
|
|
where each element references the original row. For this reason, if
|
||
|
|
you plan to keep the parsed data around in the parsing process or even
|
||
|
|
send it to another process, you must copy the data before doing the transfer,
|
||
|
|
that's why we use `:binary.copy/1` in the examples above.
|
||
|
|
|
||
|
|
## Dumping
|
||
|
|
|
||
|
|
NimbleCSV can dump any enumerable to either iodata or to streams:
|
||
|
|
|
||
|
|
iex> IO.iodata_to_binary MyParser.dump_to_iodata([~w(name age), ~w(mary 28)])
|
||
|
|
"name\tage\nmary\t28\n"
|
||
|
|
|
||
|
|
iex> MyParser.dump_to_stream([~w(name age), ~w(mary 28)])
|
||
|
|
#Stream<[
|
||
|
|
enum: [["name", "age"], ["mary", "28"]],
|
||
|
|
funs: [#Function<47.127921642/1 in Stream.map/2>]
|
||
|
|
]>
|
||
|
|
|
||
|
|
|
||
|
|
"""
|
||
|
|
|
||
|
|
defmodule ParseError do
|
||
|
|
defexception [:message]
|
||
|
|
end
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Eagerly dumps an enumerable into iodata (a list of binaries and bytes and other lists).
|
||
|
|
"""
|
||
|
|
@callback dump_to_iodata(rows :: Enumerable.t()) :: iodata()
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Lazily dumps from an enumerable to a stream.
|
||
|
|
|
||
|
|
It returns a stream that emits each row as iodata.
|
||
|
|
"""
|
||
|
|
@callback dump_to_stream(rows :: Enumerable.t()) :: Enumerable.t()
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Same as `parse_enumerable(enumerable, [])`.
|
||
|
|
"""
|
||
|
|
@callback parse_enumerable(enum :: Enumerable.t()) :: [[binary()]]
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Eagerly parses CSV from an enumerable and returns a list of rows.
|
||
|
|
|
||
|
|
Raises `NimbleCSV.ParseError` for an invalid CSV.
|
||
|
|
|
||
|
|
## Options
|
||
|
|
|
||
|
|
* `:skip_headers` - when `true`, skips headers. Defaults to `true`.
|
||
|
|
Set it to `false` to keep headers or when the CSV has no headers.
|
||
|
|
|
||
|
|
"""
|
||
|
|
@callback parse_enumerable(enum :: Enumerable.t(), opts :: keyword()) :: [[binary()]]
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Same as `parse_stream(enumerable, [])`.
|
||
|
|
"""
|
||
|
|
@callback parse_stream(enum :: Enumerable.t()) :: Enumerable.t()
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Lazily parses CSV from a stream and returns a stream of rows.
|
||
|
|
|
||
|
|
It expects the given enumerable to be line-oriented, where each
|
||
|
|
entry in the enumerable is a line. If your stream does not conform
|
||
|
|
to that, you can call `c:to_line_stream/1` before parsing the stream.
|
||
|
|
|
||
|
|
Raises `NimbleCSV.ParseError` for an invalid CSV.
|
||
|
|
|
||
|
|
## Options
|
||
|
|
|
||
|
|
* `:skip_headers` - when `true`, skips headers. Defaults to `true`.
|
||
|
|
Set it to `false` to keep headers or when the CSV has no headers.
|
||
|
|
|
||
|
|
"""
|
||
|
|
@callback parse_stream(enum :: Enumerable.t(), opts :: keyword()) :: Enumerable.t()
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Same as `parse_string(enumerable, [])`.
|
||
|
|
"""
|
||
|
|
@callback parse_string(binary()) :: [[binary()]]
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Eagerly parses CSV from a string and returns a list of rows.
|
||
|
|
|
||
|
|
Raises `NimbleCSV.ParseError` for an invalid CSV.
|
||
|
|
|
||
|
|
## Options
|
||
|
|
|
||
|
|
* `:skip_headers` - when `true`, skips headers. Defaults to `true`.
|
||
|
|
Set it to `false` to keep headers or when the CSV has no headers.
|
||
|
|
|
||
|
|
"""
|
||
|
|
@callback parse_string(binary(), opts :: keyword()) :: [[binary()]]
|
||
|
|
|
||
|
|
@doc """
|
||
|
|
Lazily convert a stream of arbitrarily chunked binaries to a line-oriented one.
|
||
|
|
|
||
|
|
This is useful for places where a CSV cannot be streamed in a
|
||
|
|
line-oriented fashion from its source.
|
||
|
|
"""
|
||
|
|
@callback to_line_stream(stream :: Enumerable.t()) :: Enumerable.t()
|
||
|
|
|
||
|
|
@doc ~S"""
|
||
|
|
Defines a new parser/dumper.
|
||
|
|
|
||
|
|
Calling this function defines a CSV module. Therefore, `define/2`
|
||
|
|
is typically invoked at the top of your files and not inside
|
||
|
|
functions. Placing it inside a function would cause the same
|
||
|
|
module to be defined multiple times, one time per invocation,
|
||
|
|
leading your code to emit warnings and slowing down execution.
|
||
|
|
|
||
|
|
It accepts the following options:
|
||
|
|
|
||
|
|
* `:moduledoc` - the documentation for the generated module
|
||
|
|
|
||
|
|
The following options control parsing:
|
||
|
|
|
||
|
|
* `:escape`- the CSV escape, defaults to `"\""`
|
||
|
|
* `:encoding` - converts the given data from encoding to UTF-8
|
||
|
|
* `:separator`- the CSV separators, defaults to `","`. It can be
|
||
|
|
a string or a list of strings. If a list is given, the first entry
|
||
|
|
is used for dumping (see below)
|
||
|
|
* `:newlines` - the list of entries to be considered newlines
|
||
|
|
when parsing, defaults to `["\r\n", "\n"]` (note they are attempted
|
||
|
|
in order, so the order matters)
|
||
|
|
* `:trim_bom` - automatically trims
|
||
|
|
[BOM (byte-order marker)](https://en.wikipedia.org/wiki/Byte_order_mark)
|
||
|
|
when parsing string. Note the BOM is not trimmed for enumerables or streams.
|
||
|
|
In such cases, the BOM must be trimmed directly in the stream, such as
|
||
|
|
`File.stream!(path, [:trim_bom])`
|
||
|
|
|
||
|
|
The following options control dumping:
|
||
|
|
|
||
|
|
* `:escape`- the CSV escape character, defaults to `"\""`
|
||
|
|
* `:encoding` - converts the given data from UTF-8 to the given encoding
|
||
|
|
* `:separator`- the CSV separator character, defaults to `","`
|
||
|
|
* `:line_separator` - the CSV line separator character, defaults to `"\n"`
|
||
|
|
* `:dump_bom` - includes BOM (byte order marker) in the dumped document
|
||
|
|
* `:reserved` - the list of characters to be escaped, defaults to the
|
||
|
|
`:separator`, `:newlines`, and `:escape` characters above
|
||
|
|
* `:escape_formula` - the formula prefix(es) and formula escape sequence,
|
||
|
|
defaults to `nil`, which disabled formula escaping
|
||
|
|
`%{["@", "+", "-", "=", "\t", "\r"] => "'"}` would escape all fields starting
|
||
|
|
with `@`, `+`, `-`, `=`, tab or carriage return using the `'` character.
|
||
|
|
|
||
|
|
Although parsing may support multiple newline delimiters, when
|
||
|
|
dumping, only one of them must be picked, which is controlled by
|
||
|
|
the `:line_separator` option. This allows NimbleCSV to handle both
|
||
|
|
`"\r\n"` and `"\n"` when parsing, but only the latter for dumping.
|
||
|
|
|
||
|
|
## Parser/Dumper API
|
||
|
|
|
||
|
|
Modules defined with `define/2` implement the `NimbleCSV` behaviour. See
|
||
|
|
the callbacks for this behaviour for information on the generated functions
|
||
|
|
and their documentation.
|
||
|
|
|
||
|
|
## CSV Injection
|
||
|
|
|
||
|
|
By default, the dumper does not escape values which some clients may interpret
|
||
|
|
as formulas or commands. This can result in
|
||
|
|
[CSV injection](https://owasp.org/www-community/attacks/CSV_Injection).
|
||
|
|
|
||
|
|
There is no universally correct way to handle CSV injections. In some cases,
|
||
|
|
you may want formulas to be preserved: you may want a cell to have a value of
|
||
|
|
`=SUM(...)`. The only way to escape these values is by materially changing
|
||
|
|
them by prefixing a tab or single quote, which can also lead to false positives.
|
||
|
|
|
||
|
|
The `escape_formula` option will add a prefix to any value which has the
|
||
|
|
configured prefix (e.g. it will prepend `'` to any value which begins with
|
||
|
|
`@`, `+`, `-`, `=`, tab or carriage return). Use the following config to
|
||
|
|
follow the [OWASP recommendations](https://owasp.org/www-community/attacks/CSV_Injection):
|
||
|
|
|
||
|
|
escape_formula: %{["@", "+", "-", "=", "\t", "\r"] => "'"}
|
||
|
|
|
||
|
|
Applications that want more control over this process, to allow formulas in specific
|
||
|
|
cases, or possibly minimize false positives, should leave this option disabled and
|
||
|
|
escape the value, as necessary, within their code.
|
||
|
|
"""
|
||
|
|
def define(module, options) do
|
||
|
|
defmodule module do
|
||
|
|
@behaviour NimbleCSV
|
||
|
|
@moduledoc Keyword.get(options, :moduledoc)
|
||
|
|
|
||
|
|
@escape Keyword.get(options, :escape, "\"")
|
||
|
|
@escape_formula Enum.to_list(Keyword.get(options, :escape_formula, []))
|
||
|
|
|
||
|
|
@separator (case Keyword.get(options, :separator, ",") do
|
||
|
|
many when is_list(many) -> many
|
||
|
|
one when is_binary(one) -> [one]
|
||
|
|
end)
|
||
|
|
|
||
|
|
@line_separator Keyword.get(options, :line_separator, "\n")
|
||
|
|
|
||
|
|
@newlines Keyword.get(options, :newlines, ["\r\n", "\n"])
|
||
|
|
|
||
|
|
@reserved Enum.uniq(
|
||
|
|
Keyword.get(
|
||
|
|
options,
|
||
|
|
:reserved,
|
||
|
|
[@escape, @line_separator] ++ @separator ++ @newlines
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# BOM and Encoding related
|
||
|
|
|
||
|
|
encoding = Keyword.get(options, :encoding, :utf8)
|
||
|
|
@bom :unicode.encoding_to_bom(encoding)
|
||
|
|
@encoding encoding
|
||
|
|
@encoded_newlines Enum.map(@newlines, &:unicode.characters_to_binary(&1, :utf8, encoding))
|
||
|
|
|
||
|
|
if Keyword.get(options, :trim_bom, false) do
|
||
|
|
defp maybe_trim_bom(@bom <> string), do: string
|
||
|
|
defp maybe_trim_bom(string), do: string
|
||
|
|
else
|
||
|
|
defp maybe_trim_bom(string), do: string
|
||
|
|
end
|
||
|
|
|
||
|
|
if Keyword.get(options, :dump_bom, false) do
|
||
|
|
defp maybe_dump_bom(list) when is_list(list), do: [@bom | list]
|
||
|
|
defp maybe_dump_bom(stream), do: Stream.concat([@bom], stream)
|
||
|
|
else
|
||
|
|
defp maybe_dump_bom(data), do: data
|
||
|
|
end
|
||
|
|
|
||
|
|
if encoding == :utf8 do
|
||
|
|
defp maybe_to_utf8(line), do: line
|
||
|
|
defp maybe_to_encoding(line), do: line
|
||
|
|
else
|
||
|
|
defp maybe_to_utf8(line) do
|
||
|
|
case :unicode.characters_to_binary(line, @encoding, :utf8) do
|
||
|
|
binary when is_binary(binary) ->
|
||
|
|
binary
|
||
|
|
|
||
|
|
reason ->
|
||
|
|
raise "error converting #{inspect(@encoding)} to :utf8, got: #{inspect(reason)}"
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_to_encoding(line) do
|
||
|
|
case :unicode.characters_to_binary(line, :utf8, @encoding) do
|
||
|
|
binary when is_binary(binary) ->
|
||
|
|
binary
|
||
|
|
|
||
|
|
reason ->
|
||
|
|
raise "error converting :utf8 to #{inspect(@encoding)}, got: #{inspect(reason)}"
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
if @escape_formula != [] do
|
||
|
|
defmacrop maybe_escape_formulas(entry) do
|
||
|
|
escapes =
|
||
|
|
for {keys, value} <- @escape_formula,
|
||
|
|
key <- keys do
|
||
|
|
quote do
|
||
|
|
<<unquote(key) <> _>> -> unquote(value)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
escapes = List.flatten(escapes) ++ quote do: (_ -> [])
|
||
|
|
|
||
|
|
quote do
|
||
|
|
case unquote(entry), do: unquote(escapes)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
else
|
||
|
|
defmacrop maybe_escape_formulas(_entry), do: []
|
||
|
|
end
|
||
|
|
|
||
|
|
_ = @bom
|
||
|
|
_ = @encoding
|
||
|
|
|
||
|
|
@compile {:inline,
|
||
|
|
maybe_dump_bom: 1, maybe_trim_bom: 1, maybe_to_utf8: 1, maybe_to_encoding: 1}
|
||
|
|
|
||
|
|
## Parser
|
||
|
|
|
||
|
|
def parse_stream(stream, opts \\ []) when is_list(opts) do
|
||
|
|
{state, separator, escape} = init_parser(opts)
|
||
|
|
|
||
|
|
Stream.transform(
|
||
|
|
stream,
|
||
|
|
fn -> state end,
|
||
|
|
&parse(maybe_to_utf8(&1), &2, separator, escape),
|
||
|
|
&finalize_parser/1
|
||
|
|
)
|
||
|
|
end
|
||
|
|
|
||
|
|
def parse_enumerable(enumerable, opts \\ []) when is_list(opts) do
|
||
|
|
{state, separator, escape} = init_parser(opts)
|
||
|
|
|
||
|
|
{lines, state} =
|
||
|
|
Enum.flat_map_reduce(
|
||
|
|
enumerable,
|
||
|
|
state,
|
||
|
|
&parse(maybe_to_utf8(&1), &2, separator, escape)
|
||
|
|
)
|
||
|
|
|
||
|
|
finalize_parser(state)
|
||
|
|
lines
|
||
|
|
end
|
||
|
|
|
||
|
|
def parse_string(string, opts \\ []) when is_binary(string) and is_list(opts) do
|
||
|
|
newline = :binary.compile_pattern(@encoded_newlines)
|
||
|
|
string = string |> maybe_trim_bom()
|
||
|
|
|
||
|
|
{0, byte_size(string)}
|
||
|
|
|> Stream.unfold(fn
|
||
|
|
{_, 0} ->
|
||
|
|
nil
|
||
|
|
|
||
|
|
{offset, length} ->
|
||
|
|
case :binary.match(string, newline, scope: {offset, length}) do
|
||
|
|
{newline_offset, newline_length} ->
|
||
|
|
difference = newline_length + newline_offset - offset
|
||
|
|
|
||
|
|
{binary_part(string, offset, difference),
|
||
|
|
{newline_offset + newline_length, length - difference}}
|
||
|
|
|
||
|
|
:nomatch ->
|
||
|
|
{binary_part(string, offset, length), {offset + length, 0}}
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|> parse_enumerable(opts)
|
||
|
|
end
|
||
|
|
|
||
|
|
def to_line_stream(stream) do
|
||
|
|
newline = :binary.compile_pattern(@encoded_newlines)
|
||
|
|
|
||
|
|
stream
|
||
|
|
|> Stream.chunk_while(
|
||
|
|
"",
|
||
|
|
&to_line_stream_chunk_fun(&1, &2, newline),
|
||
|
|
&to_line_stream_after_fun/1
|
||
|
|
)
|
||
|
|
|> Stream.concat()
|
||
|
|
end
|
||
|
|
|
||
|
|
defp to_line_stream_chunk_fun(element, acc, newline) do
|
||
|
|
to_try = acc <> element
|
||
|
|
{elements, acc} = chunk_by_newline(to_try, newline, [], {0, byte_size(to_try)})
|
||
|
|
{:cont, elements, acc}
|
||
|
|
end
|
||
|
|
|
||
|
|
@spec chunk_by_newline(binary, :binary.cp(), list(binary), tuple) :: {list(binary), binary}
|
||
|
|
defp chunk_by_newline(string, newline, elements, search_area)
|
||
|
|
|
||
|
|
defp chunk_by_newline(_string, _newline, elements, {_offset, 0}) do
|
||
|
|
{Enum.reverse(elements), ""}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp chunk_by_newline(string, newline, elements, {offset, length}) do
|
||
|
|
case :binary.match(string, newline, scope: {offset, length}) do
|
||
|
|
{newline_offset, newline_length} ->
|
||
|
|
difference = newline_length + newline_offset - offset
|
||
|
|
|
||
|
|
element = binary_part(string, offset, difference)
|
||
|
|
|
||
|
|
chunk_by_newline(
|
||
|
|
string,
|
||
|
|
newline,
|
||
|
|
[element | elements],
|
||
|
|
{newline_offset + newline_length, length - difference}
|
||
|
|
)
|
||
|
|
|
||
|
|
:nomatch ->
|
||
|
|
{Enum.reverse(elements), binary_part(string, offset, length)}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp to_line_stream_after_fun(""), do: {:cont, []}
|
||
|
|
defp to_line_stream_after_fun(acc), do: {:cont, [acc], []}
|
||
|
|
|
||
|
|
defp init_parser(opts) do
|
||
|
|
state = if Keyword.get(opts, :skip_headers, true), do: :header, else: :line
|
||
|
|
{state, :binary.compile_pattern(@separator), :binary.compile_pattern(@escape)}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp finalize_parser({:escape, _, _, _}) do
|
||
|
|
raise ParseError, "expected escape character #{@escape} but reached the end of file"
|
||
|
|
end
|
||
|
|
|
||
|
|
defp finalize_parser(_) do
|
||
|
|
:ok
|
||
|
|
end
|
||
|
|
|
||
|
|
defp to_enum(result) do
|
||
|
|
case result do
|
||
|
|
{:line, row} -> {[row], :line}
|
||
|
|
{:header, _} -> {[], :line}
|
||
|
|
{:escape, _, _, _} = escape -> {[], escape}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp parse(line, {:escape, entry, row, state}, separator, escape) do
|
||
|
|
to_enum(escape(line, entry, row, state, separator, escape))
|
||
|
|
end
|
||
|
|
|
||
|
|
defp parse(line, state, separator, escape) do
|
||
|
|
to_enum(separator(line, [], state, separator, escape))
|
||
|
|
end
|
||
|
|
|
||
|
|
defmacrop newlines_separator!() do
|
||
|
|
newlines_offsets =
|
||
|
|
for {newline, i} <- Enum.with_index(@newlines) do
|
||
|
|
quote do
|
||
|
|
unquote(Macro.var(:"count#{i}", Elixir)) = offset - unquote(byte_size(newline))
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
newlines_clauses =
|
||
|
|
@newlines
|
||
|
|
|> Enum.with_index()
|
||
|
|
|> Enum.flat_map(fn {newline, i} ->
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^unquote(Macro.var(:"count#{i}", Elixir)))-binary, unquote(newline)>> ->
|
||
|
|
prefix
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|> Kernel.++(quote do: (prefix -> prefix))
|
||
|
|
|
||
|
|
quote generated: true do
|
||
|
|
offset = byte_size(var!(line))
|
||
|
|
unquote(newlines_offsets)
|
||
|
|
case var!(line), do: unquote(newlines_clauses)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defmacrop separator_case() do
|
||
|
|
clauses =
|
||
|
|
Enum.flat_map(@separator, fn sep ->
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^var!(pos))-binary, unquote(sep), @escape, rest::binary>> ->
|
||
|
|
escape(
|
||
|
|
rest,
|
||
|
|
"",
|
||
|
|
var!(row) ++ :binary.split(prefix, var!(separator), [:global]),
|
||
|
|
var!(state),
|
||
|
|
var!(separator),
|
||
|
|
var!(escape)
|
||
|
|
)
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|
||
|
|
catch_all =
|
||
|
|
quote do
|
||
|
|
_ ->
|
||
|
|
raise ParseError,
|
||
|
|
"unexpected escape character #{@escape} in #{inspect(var!(line))}"
|
||
|
|
end
|
||
|
|
|
||
|
|
quote do
|
||
|
|
case var!(line) do
|
||
|
|
unquote(clauses ++ catch_all)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp separator(line, row, state, separator, escape) do
|
||
|
|
case :binary.match(line, escape) do
|
||
|
|
{0, _} ->
|
||
|
|
<<@escape, rest::binary>> = line
|
||
|
|
escape(rest, "", row, state, separator, escape)
|
||
|
|
|
||
|
|
{pos, _} ->
|
||
|
|
pos = pos - 1
|
||
|
|
separator_case()
|
||
|
|
|
||
|
|
:nomatch ->
|
||
|
|
pruned = newlines_separator!()
|
||
|
|
{state, row ++ :binary.split(pruned, separator, [:global])}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defmacrop newlines_escape!(match) do
|
||
|
|
newlines_before =
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^offset)-binary, @escape, @escape, rest::binary>> ->
|
||
|
|
escape(
|
||
|
|
rest,
|
||
|
|
var!(entry) <> prefix <> <<@escape>>,
|
||
|
|
var!(row),
|
||
|
|
var!(state),
|
||
|
|
var!(separator),
|
||
|
|
var!(escape)
|
||
|
|
)
|
||
|
|
end ++
|
||
|
|
Enum.flat_map(@separator, fn sep ->
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^offset)-binary, @escape, unquote(sep), rest::binary>> ->
|
||
|
|
separator(
|
||
|
|
rest,
|
||
|
|
var!(row) ++ [var!(entry) <> prefix],
|
||
|
|
var!(state),
|
||
|
|
var!(separator),
|
||
|
|
var!(escape)
|
||
|
|
)
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|
||
|
|
newlines_clauses =
|
||
|
|
Enum.flat_map(@newlines, fn newline ->
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^offset)-binary, @escape, unquote(newline)>> ->
|
||
|
|
{var!(state), var!(row) ++ [var!(entry) <> prefix]}
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|
||
|
|
newlines_after =
|
||
|
|
quote do
|
||
|
|
<<prefix::size(^offset)-binary, @escape>> ->
|
||
|
|
{var!(state), var!(row) ++ [var!(entry) <> prefix]}
|
||
|
|
|
||
|
|
_ ->
|
||
|
|
raise ParseError, "unexpected escape character #{@escape} in #{inspect(var!(line))}"
|
||
|
|
end
|
||
|
|
|
||
|
|
quote do
|
||
|
|
case unquote(match) do
|
||
|
|
{offset, _} ->
|
||
|
|
case var!(line), do: unquote(newlines_before ++ newlines_clauses ++ newlines_after)
|
||
|
|
|
||
|
|
:nomatch ->
|
||
|
|
{:escape, var!(entry) <> var!(line), var!(row), var!(state)}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp escape(line, entry, row, state, separator, escape) do
|
||
|
|
newlines_escape!(:binary.match(line, escape))
|
||
|
|
end
|
||
|
|
|
||
|
|
@compile {:inline, init_parser: 1, to_enum: 1, parse: 4}
|
||
|
|
|
||
|
|
## Dumper
|
||
|
|
|
||
|
|
def dump_to_iodata(enumerable) do
|
||
|
|
check = init_dumper()
|
||
|
|
|
||
|
|
enumerable
|
||
|
|
|> Enum.map(&dump(&1, check))
|
||
|
|
|> maybe_dump_bom()
|
||
|
|
end
|
||
|
|
|
||
|
|
def dump_to_stream(enumerable) do
|
||
|
|
check = init_dumper()
|
||
|
|
|
||
|
|
enumerable
|
||
|
|
|> Stream.map(&dump(&1, check))
|
||
|
|
|> maybe_dump_bom()
|
||
|
|
end
|
||
|
|
|
||
|
|
@encoded_escape (case @escape
|
||
|
|
|> :unicode.characters_to_binary(:utf8, encoding) do
|
||
|
|
<<x>> -> x
|
||
|
|
x -> x
|
||
|
|
end)
|
||
|
|
|
||
|
|
@encoded_separator (case @separator
|
||
|
|
|> hd()
|
||
|
|
|> :unicode.characters_to_binary(:utf8, encoding) do
|
||
|
|
<<x>> -> x
|
||
|
|
x -> x
|
||
|
|
end)
|
||
|
|
|
||
|
|
@encoded_line_separator (case @line_separator
|
||
|
|
|> :unicode.characters_to_binary(:utf8, encoding) do
|
||
|
|
<<x>> -> x
|
||
|
|
x -> x
|
||
|
|
end)
|
||
|
|
|
||
|
|
@replacement @escape <> @escape
|
||
|
|
|
||
|
|
defp init_dumper() do
|
||
|
|
:binary.compile_pattern(@reserved)
|
||
|
|
end
|
||
|
|
|
||
|
|
defp dump([], _check) do
|
||
|
|
[@encoded_line_separator]
|
||
|
|
end
|
||
|
|
|
||
|
|
defp dump([entry], check) do
|
||
|
|
[maybe_escape(entry, check), @encoded_line_separator]
|
||
|
|
end
|
||
|
|
|
||
|
|
defp dump([entry | entries], check) do
|
||
|
|
[maybe_escape(entry, check), @encoded_separator | dump(entries, check)]
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_escape(entry, check) do
|
||
|
|
entry = to_string(entry)
|
||
|
|
|
||
|
|
case :binary.match(entry, check) do
|
||
|
|
{_, _} ->
|
||
|
|
replaced = :binary.replace(entry, @escape, @replacement, [:global])
|
||
|
|
|
||
|
|
[
|
||
|
|
@encoded_escape,
|
||
|
|
maybe_escape_formulas(entry),
|
||
|
|
maybe_to_encoding(replaced),
|
||
|
|
@encoded_escape
|
||
|
|
]
|
||
|
|
|
||
|
|
:nomatch ->
|
||
|
|
[maybe_escape_formulas(entry), maybe_to_encoding(entry)]
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
@compile {:inline, init_dumper: 0, maybe_escape: 2}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
NimbleCSV.define(NimbleCSV.RFC4180,
|
||
|
|
separator: ",",
|
||
|
|
escape: "\"",
|
||
|
|
line_separator: "\r\n",
|
||
|
|
moduledoc: """
|
||
|
|
A CSV parser that uses comma as separator and double-quotes as escape according to RFC4180.
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
|
||
|
|
NimbleCSV.define(NimbleCSV.Spreadsheet,
|
||
|
|
separator: "\t",
|
||
|
|
escape: "\"",
|
||
|
|
encoding: {:utf16, :little},
|
||
|
|
trim_bom: true,
|
||
|
|
dump_bom: true,
|
||
|
|
moduledoc: """
|
||
|
|
A parser with spreadsheet friendly settings.
|
||
|
|
|
||
|
|
The parser uses tab as separator and double-quotes as escape, as required by
|
||
|
|
common spreadsheet software such as Excel, Numbers and OpenOffice. It's encoded
|
||
|
|
in UTF-16 little-endian with a byte-order BOM.
|
||
|
|
|
||
|
|
Such files should still be saved with the `.csv` extension.
|
||
|
|
"""
|
||
|
|
)
|