Current section
Files
Jump to
Current section
Files
src/erl_csv_decoder.erl
-module(erl_csv_decoder).
-moduledoc false.
-include("erl_csv.hrl").
-export([decode/2, decode_new_s/2, decode_s/1]).
-record(csv_decoder, {
line_break = ?DELIMITER :: binary(),
line_break_size = 1 :: pos_integer(),
separator = ?SEPARATOR :: <<_:8>>,
quotes = ?QUOTES :: <<_:8>>,
quote_byte = $" :: byte(),
terminators :: binary:cp() | undefined
}).
-type csv_decoder() :: #csv_decoder{}.
%% Result of parsing a single field: which token terminated it, the field value,
%% and the offset right after the terminator. `incomplete' means the field (and
%% therefore its row) could not be completed within this chunk.
-type field_result() ::
{separator | delimiter, binary(), non_neg_integer()} | incomplete.
-spec decode(iodata(), erl_csv:decode_opts()) ->
{ok, iodata()} | {has_trailer, iodata(), iodata()} | {nomatch, iodata()}.
decode(Chunk, Opts) ->
Bin = iolist_to_binary(Chunk),
Separator = maps:get(separator, Opts, ?SEPARATOR),
Delimiter = maps:get(delimiter, Opts, ?DELIMITER),
Quotes = maps:get(quotes, Opts, ?QUOTES),
<<QuoteByte>> = Quotes,
State = #csv_decoder{
separator = Separator,
line_break = Delimiter,
line_break_size = byte_size(Delimiter),
quotes = Quotes,
quote_byte = QuoteByte,
terminators = binary:compile_pattern([Separator, Delimiter])
},
decode_rows(Bin, 0, byte_size(Bin), State, []).
-spec decode_new_s(file:name_all(), erl_csv:decode_opts()) ->
{ok, erl_csv:csv_stream()} | {error, term()}.
decode_new_s(File, Opts) ->
case erl_csv_file_stream:read_file(File, Opts) of
#csv_stream{} = Stream ->
{ok, Stream};
{error, Reason} ->
{error, Reason}
end.
-spec decode_s(erl_csv:maybe_csv_stream()) ->
{ok, iodata(), erl_csv:csv_stream()} | {error, term()}.
decode_s(stream_end) ->
{ok, [], stream_end};
decode_s(#csv_stream{hd = Bin, opts = Opts} = Stream) ->
case decode(Bin, Opts) of
{ok, Decoded} ->
{ok, Decoded, Stream#csv_stream{hd = <<>>}};
{has_trailer, Decoded, Trailer} ->
SavedStream = Stream#csv_stream{hd = Trailer},
case get_more_stream(SavedStream) of
{error, Reason} -> {error, Reason};
MoreStream -> {ok, Decoded, MoreStream}
end;
{nomatch, NotMatched} ->
SavedStream = Stream#csv_stream{hd = NotMatched},
decode_s(get_more_stream(SavedStream))
end;
decode_s({error, Reason}) ->
{error, Reason}.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-spec get_more_stream(erl_csv:csv_stream()) -> erl_csv:maybe_csv_stream().
get_more_stream(Stream) ->
case erl_csv_file_stream:tl(Stream) of
{error, Reason} ->
{error, Reason};
NewStream ->
NewStream
end.
%% Parse as many complete rows (rows terminated by an unquoted delimiter) as the
%% chunk holds. Anything from the start of the first incomplete row onwards is
%% returned to the caller as a trailer so it can be prepended to the next chunk.
-spec decode_rows(binary(), non_neg_integer(), non_neg_integer(), csv_decoder(), [[binary()]]) ->
{ok, [[binary()]]} | {has_trailer, [[binary()]], binary()} | {nomatch, binary()}.
decode_rows(Bin, Pos, Size, _State, Acc) when Pos >= Size ->
case Acc of
[] -> {nomatch, Bin};
_ -> {ok, lists:reverse(Acc)}
end;
decode_rows(Bin, Pos, Size, State, Acc) ->
case decode_row(Bin, Pos, Pos, Size, State, []) of
{row, Fields, Next} ->
decode_rows(Bin, Next, Size, State, [Fields | Acc]);
{trailer, RowStart} ->
Trailer = binary:part(Bin, RowStart, Size - RowStart),
trailer_result(Bin, Trailer, State, Acc)
end.
-spec trailer_result(binary(), binary(), csv_decoder(), [[binary()]]) ->
{has_trailer, [[binary()]], binary()} | {nomatch, binary()}.
trailer_result(Bin, Trailer, #csv_decoder{terminators = Terminators}, []) ->
%% No complete row was produced. Keep the historical distinction: when the
%% chunk holds no separator nor delimiter at all it is a `nomatch', otherwise
%% it is a partial row worth carrying over as a trailer.
case binary:match(Bin, Terminators) of
nomatch -> {nomatch, Trailer};
_ -> {has_trailer, [], Trailer}
end;
trailer_result(_Bin, Trailer, _State, Acc) ->
{has_trailer, lists:reverse(Acc), Trailer}.
-spec decode_row(
binary(), non_neg_integer(), non_neg_integer(), non_neg_integer(), csv_decoder(), [binary()]
) ->
{row, [binary()], non_neg_integer()} | {trailer, non_neg_integer()}.
decode_row(Bin, RowStart, Pos, Size, State, Fields) ->
case decode_field(Bin, Pos, Size, State) of
{separator, Field, Next} ->
decode_row(Bin, RowStart, Next, Size, State, [Field | Fields]);
{delimiter, Field, Next} ->
{row, lists:reverse([Field | Fields]), Next};
incomplete ->
{trailer, RowStart}
end.
-spec decode_field(binary(), non_neg_integer(), non_neg_integer(), csv_decoder()) ->
field_result().
decode_field(_Bin, Pos, Size, _State) when Pos >= Size ->
%% A separator was the last byte of the chunk: the trailing field (and its
%% row) is incomplete until more data arrives.
incomplete;
decode_field(Bin, Pos, Size, #csv_decoder{quote_byte = QuoteByte} = State) ->
case binary:at(Bin, Pos) of
QuoteByte -> decode_quoted_field(Bin, Pos + 1, Pos + 1, Size, State);
_ -> decode_unquoted_field(Bin, Pos, Size, State)
end.
-spec decode_unquoted_field(binary(), non_neg_integer(), non_neg_integer(), csv_decoder()) ->
field_result().
decode_unquoted_field(Bin, Pos, Size, #csv_decoder{terminators = Terminators} = State) ->
case binary:match(Bin, Terminators, [{scope, {Pos, Size - Pos}}]) of
nomatch ->
incomplete;
{TermPos, TermLen} ->
Field = binary:part(Bin, Pos, TermPos - Pos),
{kind_of_terminator(Bin, TermPos, TermLen, State), Field, TermPos + TermLen}
end.
%% A field that opens with the quote character. Scan for the matching closing
%% quote, treating a doubled quote (`""') as an escaped literal quote.
-spec decode_quoted_field(
binary(), non_neg_integer(), non_neg_integer(), non_neg_integer(), csv_decoder()
) ->
field_result().
decode_quoted_field(Bin, ContentStart, SearchPos, Size, State) ->
decode_quoted_field(Bin, ContentStart, SearchPos, Size, State, false).
-spec decode_quoted_field(
binary(), non_neg_integer(), non_neg_integer(), non_neg_integer(), csv_decoder(), boolean()
) ->
field_result().
decode_quoted_field(Bin, ContentStart, SearchPos, Size, State, Escaped) ->
#csv_decoder{quotes = Q} = State,
case binary:match(Bin, Q, [{scope, {SearchPos, Size - SearchPos}}]) of
nomatch ->
incomplete;
{QuotePos, _} ->
AfterQuote = QuotePos + 1,
case is_escaped_quote(Bin, AfterQuote, Size, Q) of
true ->
decode_quoted_field(Bin, ContentStart, AfterQuote + 1, Size, State, true);
false ->
close_quoted_field(
Bin, ContentStart, QuotePos, AfterQuote, Size, State, Escaped
)
end
end.
-spec close_quoted_field(
binary(),
non_neg_integer(),
non_neg_integer(),
non_neg_integer(),
non_neg_integer(),
csv_decoder(),
boolean()
) ->
field_result().
close_quoted_field(Bin, ContentStart, QuotePos, AfterQuote, Size, State, Escaped) ->
case terminator_at(Bin, AfterQuote, Size, State) of
{Kind, Next} ->
{Kind, quoted_value(Bin, ContentStart, QuotePos, State, Escaped), Next};
eof ->
%% Closing quote but no terminator yet: the row is not complete.
incomplete;
garbage ->
%% Content between the closing quote and the terminator is malformed;
%% fall back to reading the whole field (from the opening quote)
%% verbatim rather than dropping data.
decode_unquoted_field(Bin, ContentStart - 1, Size, State)
end.
%% Only pay for un-escaping (a full binary:replace scan and copy) when a doubled
%% quote was actually seen; otherwise the value is the raw slice as-is.
-spec quoted_value(binary(), non_neg_integer(), non_neg_integer(), csv_decoder(), boolean()) ->
binary().
quoted_value(Bin, ContentStart, QuotePos, _State, false) ->
binary:part(Bin, ContentStart, QuotePos - ContentStart);
quoted_value(Bin, ContentStart, QuotePos, State, true) ->
unescape(Bin, ContentStart, QuotePos, State).
-spec is_escaped_quote(binary(), non_neg_integer(), non_neg_integer(), <<_:8>>) -> boolean().
is_escaped_quote(_Bin, AfterQuote, Size, _Q) when AfterQuote >= Size ->
false;
is_escaped_quote(Bin, AfterQuote, _Size, Q) ->
binary:part(Bin, AfterQuote, 1) =:= Q.
%% Classify the terminator that binary:match/3 landed on for an unquoted field.
-spec kind_of_terminator(binary(), non_neg_integer(), non_neg_integer(), csv_decoder()) ->
separator | delimiter.
kind_of_terminator(_Bin, _TermPos, TermLen, #csv_decoder{line_break_size = DelSize}) when
TermLen =/= DelSize
->
%% Different length than the delimiter, so it can only be the separator.
separator;
kind_of_terminator(Bin, TermPos, TermLen, #csv_decoder{line_break = Delimiter}) ->
case binary:part(Bin, TermPos, TermLen) of
Delimiter -> delimiter;
_ -> separator
end.
%% Determine what, if anything, terminates a field at the given position.
-spec terminator_at(binary(), non_neg_integer(), non_neg_integer(), csv_decoder()) ->
{separator | delimiter, non_neg_integer()} | eof | garbage.
terminator_at(_Bin, Pos, Size, _State) when Pos >= Size ->
eof;
terminator_at(Bin, Pos, Size, #csv_decoder{
separator = Sep, line_break = Delimiter, line_break_size = DelSize
}) ->
case Pos + DelSize =< Size andalso binary:part(Bin, Pos, DelSize) =:= Delimiter of
true ->
{delimiter, Pos + DelSize};
false ->
SepSize = byte_size(Sep),
case binary:part(Bin, Pos, SepSize) =:= Sep of
true -> {separator, Pos + SepSize};
false -> garbage
end
end.
-spec unescape(binary(), non_neg_integer(), non_neg_integer(), csv_decoder()) -> binary().
unescape(Bin, ContentStart, QuotePos, #csv_decoder{quotes = Q}) ->
Raw = binary:part(Bin, ContentStart, QuotePos - ContentStart),
binary:replace(Raw, <<Q/binary, Q/binary>>, Q, [global]).