src/stringx.erl

%%% vim:ts=2:sw=2:et
%%%------------------------------------------------------------------------
%%% @doc Implements miscelaneous string functions
%%%
%%% @author Serge Aleynikov <saleyn@gmail.com>
%%% @copyright 2006 Serge Aleynikov
%%% @end
%%% Some implementation authored by Evan Miller:
%%% http://www.evanmiller.org/joy-of-erlang.html
%%%------------------------------------------------------------------------
%%% Created 2005-09-25
%%%------------------------------------------------------------------------
%%% format_number/3,4, format_price/1,2,3 functions are taken from
%%% [https://github.com/DOBRO/uef-lib/blob/master/src/uef_format.erl]
%%% Copyright (c) 2019, Sergei Semichev <chessvegas@chessvegas.com>
%%%------------------------------------------------------------------------
-module(stringx).
-author('saleyn@gmail.com').

%% External API
-export([titlecase/1, wordwrap/2, wordwrap/3]).
-export([pretty_table/1, pretty_table/2, pretty_table/3]).
-export([pretty_print_table/1, pretty_print_table/2, pretty_print_table/3]).
-export([align_rows/1, align_rows/2, aligned_format/2, aligned_format/3]).

-export([format/2, format_binary/2]).
-export([format_integer/1, format_integer/2, format_number/3, format_number/4]).
-export([format_price/1, format_price/2, format_price/3]).
-export([round_price/1, round_number/2, binary_join/2]).
-export([parse_csv/1, parse_csv/2, batch_split/2]).

-type formatted_number() :: binary().
-type precision()        :: integer().
-type decimals()         :: 0..253. % see types for erlang:float_to_binary/2
-type ccy_sym()          :: binary() | string().
-type format_number_opts() :: #{
	thousands     => binary() | string(),
	decimal_point => binary() | string(),  % Default: "."
	ccy_sym       => ccy_sym(),
	ccy_pos       => left | right,
	ccy_sep       => binary() | string(),
  return        => binary | list         % Default: binary
}.

-type pretty_print_opts() :: #{
  number_pad => char(),    % Padding character for numbers
  header     => boolean(), % Output header row
  th_dir     => both|leading|trailing, % table header padding dir
  td_dir     => both|leading|trailing, % table row    padding dir
  td_start   => integer(), % Start printing from this field number
  td_exclude => list(),    % Exclude columns (start with 1) or names
  td_sep     => string(),  % Column separator
  tr_sep     => string(),
  tr_sep_td  => string(),  % Delimiter header/footer column sep
  prefix     => string(),  % Use this prefix in front of each row
  thousands  => string()|binary(),       % Thousands separator for numbers
  translate  => fun((term()) -> term()), % Value translation function `(Val) -> any()`
  footer_rows=> integer(), % Number of footer rows
  td_formats => tuple()|
                fun((ColVal::term()) -> {string, string()}|
                                        {number, string()|number()}|
                                        {number, Decimals::integer(), ColVal::number()}|
                                        {ccy,    number()}),  % Optional tuple containing value format for columns
  thousands  => string()|binary(),       % Number thousands separator
  ccy_sym    => string()|binary(),       % Currency prefix/suffix
  ccy_sep    => string()|binary(),       % Currency symbol separator
  ccy_pos    => left|right               % Currency symbol position
}.

-export_type([format_number_opts/0, pretty_print_opts/0]).

-include("stringx.hrl").
-include("iif.hrl").

%%%------------------------------------------------------------------------
%%% External API
%%%------------------------------------------------------------------------

%%-------------------------------------------------------------------------
%% @doc Convert words in a string to capitalize first letter of each word.
%% @end
%%-------------------------------------------------------------------------
-spec titlecase(string()) -> string().
titlecase(S) when is_list(S) ->
  titlecase(S, []).

%%-------------------------------------------------------------------------
%% @doc Wrap words words in a string
%% @end
%%-------------------------------------------------------------------------
-spec wordwrap(string(), integer()) -> string().
wordwrap(S, Margin) when is_list(S), is_integer(Margin) ->
  wordwrap(S, [], [], 0, Margin).

%%-------------------------------------------------------------------------
%% @doc Wrap words words in a string to multiple lines that fit the margin
%% Example:
%% <code>
%% 1> stringx:wordwrap(["abc", "efg", "exdf"], 8, ",").
%% ["abc,efg,","exdf"]
%% </code>
%% @end
%%-------------------------------------------------------------------------
-spec wordwrap([Word], integer(), Word) -> [string()|binary()]
        when Word :: string()|binary().
wordwrap(Words, Margin, Delim) ->
  wrap(Words, [[]], Margin, Delim).
 
%%-------------------------------------------------------------------------
%% @doc Pretty print list of maps to list.
%% @see pretty_table/3.
%% @end
%%-------------------------------------------------------------------------
-spec pretty_table([map()]) -> list().
pretty_table([Map|_] = LofMaps0) when is_map(Map) ->
  pretty_table(lists:sort(maps:keys(Map)), LofMaps0, #opts{}).

%%-------------------------------------------------------------------------
%% @doc Pretty print table of lists/tuples/maps to list.
%% @see pretty_table/3.
%% @end
%%-------------------------------------------------------------------------
-spec pretty_table([string()], [Row :: tuple()|list()|map()]) -> list().
pretty_table(HeaderRowKeys, Rows) ->
  pretty_table(HeaderRowKeys, Rows, #opts{}).

-spec pretty_table([string()|atom()]|tuple(), [Row :: tuple()|list()|map()],
                   Opts::map()|#opts{}) -> list().
pretty_table(HeaderRowKeys, Rows, MapOpts) when is_map(MapOpts) ->
  pretty_table0(HeaderRowKeys, Rows, MapOpts);

pretty_table(HeaderRowKeys, Rows, #opts{} = Opts) ->
  pretty_table1(HeaderRowKeys, Rows, Opts).

%%-------------------------------------------------------------------------
%% @doc Pretty print table of lists/tuples/maps to list.
%% The following options control formatting behavior:
%% <dl>
%% <dt>number_pad</dt>
%%      <dd>Leading padding character used for numbers</dd>
%% <dt>th_dir</dt>
%%      <dd>Table header row padding direction (both|leading|trailing)</dd>
%% <dt>td_dir</dt>
%%      <dd>Table row padding direction (both|leading|trailing)</dd>
%% <dt>td_start</dt>
%%      <dd>Don't print columns less than this (e.g. use 2 for records)</dd>
%% <dt>td_exclulde</dt>
%%      <dd>List of column ID's (starting with 1) or names to exclude</dd>
%% <dt>td_sep</dt>
%%      <dd>Column separator (default `" | "')</dd>
%% <dt>tr_sep</dt>
%%      <dd>Row separator (default `"-"')</dd>
%% <dt>tr_sep_td</dt>
%%      <dd>Column delimiter used in separating rows (`"+"')</dd>
%% <dt>prefix</dt>
%%      <dd>Prepend each row with this string</dd>
%% <dt>td_formats</dt>
%%      <dd>A tuple containing column formats. Each value is either
%%          a format string passed to `io_lib:format/2' or a function taking
%%          either one argument
%%          `fun(Value) -> {number|string, FormattedValue::string()}'
%%          or three arguments
%%          `fun(Key,Value,Row::tuple()|map()) -> {number|string, FormattedValue::string()}'.
%%          This three argument function can perform calculation of the field
%%          value based on values of other fields in the `Row'.
%%      </dd>
%% <dt>unicode</dt>
%%      <dd>Use unicode outline characters</dd>
%% <dt>outline</dt>
%%      <dd>Draw top, left and line box outline (by default only the bottom one is drawn).
%%          Values:
%%          <ul>
%%          <li>`none' - on outline box</li>
%%          <li>`full' - outline box on all 4 sides</li>
%%          <li>`[top, left, bottom, right]' - outline box on given sides</li>
%%          </ul>
%%      </dd>
%% </dl>
%% Example:
%% ```
%% 1> stringx:pretty_print_table(
%%      {a,b,c,d}, [{a, 10, ccc}, {bxxx, 200.00123, 'Done'}, {abc, 100.0, xx}],
%%      #opts{td_dir=both, td_exclude=[d], td_formats=
%%          {undefined, fun(V) when is_integer(V) -> {number, integer_to_list(V)};
%%                         (V) when is_float(V)   -> {number, float_to_list(V, [{decimals, 5}])}
%%                      end, "~w"}}).
%%  a   |     b     |   c
%% -----+-----------+-------
%%  a   |        10 |  ccc
%% bxxx | 200.00123 | 'Done'
%% -----+-----------+-------
%% '''
%% @end
%%-------------------------------------------------------------------------
pretty_print_table([Map|_] = LofMaps0) when is_map(Map) ->
  io:put_chars(pretty_table1(lists:sort(maps:keys(Map)), LofMaps0, #opts{})).

pretty_print_table(HeaderRowKeys, Rows) ->
  io:put_chars(pretty_table1(HeaderRowKeys, Rows, #opts{})).

pretty_print_table(HeaderRowKeys, Rows, Opts) ->
  io:put_chars(pretty_table0(HeaderRowKeys, Rows, Opts)).

%%%------------------------------------------------------------------------
%%% Internal functions
%%%------------------------------------------------------------------------

titlecase([], Acc) ->
  lists:reverse(Acc);
titlecase([C | T], [] = Acc) when C >= $a, C =< $z ->
  titlecase(T, [C + ($A - $a) | Acc]);
titlecase([C | T], [$  |_] = Acc) when C >= $a, C =< $z ->
  titlecase(T, [C + ($A - $a) | Acc]);
titlecase([C | T], Acc) ->
  titlecase(T, [C | Acc]).

wordwrap([], Acc, WordAcc, _LineLen, _Margin) ->
  lists:reverse(WordAcc ++ Acc);

% Premature newline
wordwrap([$\n | Rest], Acc, WordAcc, _LineLen, Margin) ->
  wordwrap(Rest, [$\n | dropws(WordAcc, Acc)], [], 0, Margin);

% Reached the wrap length at a space character - add $\n
wordwrap([$  | Rest], Acc, WordAcc, Margin, Margin) ->
  wordwrap(Rest, [$\n | dropws(WordAcc, Acc)], [], 0, Margin);

% Found space character before the wrap length - continue
wordwrap([$  | Rest], Acc, WordAcc, LineLen, Margin) ->
  wordwrap(Rest, [$  | WordAcc ++ Acc], [], LineLen + 1 + length(WordAcc), Margin);

% Overflowed the current line while building a word
wordwrap([C | Rest], Acc, WordAcc, 0, Margin) when erlang:length(WordAcc) > Margin ->
  wordwrap(Rest, Acc, [C | WordAcc], 0, Margin);
wordwrap([C | Rest], Acc, WordAcc, LineLen, Margin) 
  when erlang:length(WordAcc) + LineLen > Margin ->
  wordwrap(Rest, [$\n | dropws(Acc, [])], [C | WordAcc], 0, Margin);

% Just building a word...
wordwrap([C | Rest], Acc, WordAcc, LineLen, Margin) ->
  wordwrap(Rest, Acc, [C | WordAcc], LineLen, Margin).


% * All done, return the result
wrap([], Result, _Margin, _Delim) ->
  lists:map(fun
    ([])  -> [];
    ([L]) -> [L];
    (L)   -> lists:flatten(L)
  end, lists:reverse(Result));

wrap([Word | Rest], [CurrLine | PrevLines], Margin, Delim) ->
  Width = iolist_size(Word) + iolist_size(CurrLine) + iolist_size(Delim),
  Fits  = Width =< Margin,
  if
    % Adding word(s) to an empty line
    CurrLine == [], Rest == [] ->
      % This is the last word
      wrap([],   [Word | PrevLines], Margin, Delim);
    CurrLine == [] ->
      wrap(Rest, [[Word, Delim] | PrevLines], Margin, Delim);

    % Adding to a partially filled line, where the word fits the margin?
    Fits, Rest == [] ->
      % This is the last word
      wrap([],   [[CurrLine, Word] | PrevLines], Margin, Delim);
    Fits ->
      wrap(Rest, [[CurrLine, Word, Delim] | PrevLines], Margin, Delim);

    % The word does not fit the line, append it to the new one.
    not Fits, Rest == [] ->
      % This is the last word
      wrap([],   [Word, CurrLine | PrevLines], Margin, Delim);
    not Fits ->
      wrap(Rest, [[Word, Delim], CurrLine | PrevLines], Margin, Delim);
    true ->
      erlang:raise(logic_error)
  end.
 
dropws(Word, Acc) -> dropws2(dropws1(Word), Acc).

dropws1([$ |T]) -> dropws1(T);
dropws1(L     ) -> L.

dropws2([],   Acc) -> dropws1(Acc);
dropws2(Word, Acc) -> Word ++ Acc.

translate_excludes(_,  I, _) when I==undefined; I==[] -> [];
translate_excludes([], _, _)                          -> [];
translate_excludes(ColNames, ExcludeNamesAndPos, StartPos) ->
  {IDs, Names} = lists:partition(fun(I) -> is_integer(I) end, ExcludeNamesAndPos),
  Cols         = if is_tuple(ColNames) -> ColNames; true -> list_to_tuple(ColNames) end,
  TransNames   = fun G([_|L], I, T) when I > tuple_size(Cols) -> G(L,1,T);
                     G([A|L], I, T) when element(I,Cols) == A -> G(L,I+1,setelement(I, T, true));
                     G([],   _I, T) -> T;
                     G(L,     I, T) -> G(L,I+1,T)
                 end,
  BaseExcludes = erlang:make_tuple(tuple_size(Cols), false, [{I, true} || I <- IDs]),
  Excludes     = TransNames(Names, 1, BaseExcludes),
  take_nth(StartPos, tuple_to_list(Excludes)).

filter_out([], _)              -> [];
filter_out([_|T1], [true |T2]) -> filter_out(T1, T2);
filter_out([H|T1], [false|T2]) -> [H|filter_out(T1, T2)];
filter_out([H|T1], []) ->         [H|filter_out(T1, [])].

pretty_table0(HdrRowKeys, Rows, #opts{} = Opts) ->
  pretty_table1(HdrRowKeys, Rows, Opts);
pretty_table0(HdrRowKeys, Rows, MapOpts) when is_map(MapOpts) ->
  DefTup  = #opts{},
  DefOpts = maps:from_list(lists:zip(record_info(fields, opts), tl(tuple_to_list(DefTup)))),
  MOpts   = maps:merge(DefOpts, MapOpts),
  #{number_pad:=NP, header:=OH,      th_dir:=THD, td_dir:=TDD,
    td_start:=TDST, td_exclude:=TDE, td_sep:=TDS, tr_sep:=TRS, tr_sep_td:=TRSTD,
    prefix:=Prf,    translate :=TR,  footer_rows:=FR,          td_formats:=TDF,
    thousands:=THS, ccy_sym:=CCY,    ccy_sep    :=CS,          ccy_pos:=CP,
    unicode  :=UNI, outline:=OUTLINE} = MOpts,
  Opts = #opts{
    number_pad = NP,
    header     = OH,
    th_dir     = THD,
    td_dir     = TDD,
    td_start   = TDST,
    td_exclude = TDE,
    td_sep     = ?IIF(UNI andalso TDS   == DefTup#opts.td_sep,   " │ ", TDS),
    tr_sep     = ?IIF(UNI andalso TRS   == DefTup#opts.tr_sep,   "─",   TRS),
    tr_sep_td  = ?IIF(UNI andalso TRSTD == DefTup#opts.tr_sep_td,"┼",   TRSTD),
    prefix     = Prf,
    translate  = TR,
    footer_rows= FR,
    td_formats = TDF,
    thousands  = THS,
    ccy_sym    = CCY,
    ccy_sep    = CS,
    ccy_pos    = CP,
    outline    = OUTLINE,
    unicode    = UNI
  },
  pretty_table1(HdrRowKeys, Rows, Opts).

pretty_table1(Keys0, Rows0, Opts) when is_tuple(Keys0) ->
  pretty_table1(tuple_to_list(Keys0), Rows0, Opts);
pretty_table1(Keys0, Rows0, #opts{unicode = Uni} = Opts) when is_list(Keys0), is_list(Rows0) ->
  Exclude = translate_excludes(Keys0, Opts#opts.td_exclude, Opts#opts.td_start),
  KeyStrs = take_nth(Opts#opts.td_start, [element(2, to_string(Key)) || Key <- Keys0]),
  Rows    = [take_nth(Opts#opts.td_start, to_strings(Keys0, V, Opts)) || V <- Rows0],
  Ws      = ws(Rows, [{undefined, string:length(Key)} || Key <- KeyStrs]),
  Col     = fun
              ({number,Str}, {_, Width}) ->
                string:pad(Str, Width, leading, Opts#opts.number_pad);
              ({_,undefined}, {_,Width}) when is_atom(Opts#opts.td_dir) ->
                string:pad("", Width,  Opts#opts.td_dir, $\s);
              ({_,Str}, {_, Width}) when is_atom(Opts#opts.td_dir) ->
                string:pad(Str, Width, Opts#opts.td_dir, $\s);
              ({_,_Str}, {_, _Width}) ->
                throw({invalid_option, td_dir, Opts#opts.td_dir})
            end,
  #{top:=BoxT, bottom:=BoxB, left:=BoxL,right:=BoxR} = to_outline(Opts#opts.outline),
  {BoxTL,BoxTR,BoxBHL,BoxEHL,BoxBOL,BoxEOL,BoxBL,BoxBR,BoxTC,BoxBC} =
    case {BoxT or BoxB or BoxL or BoxR, Uni} of
      {false,_} -> {"", "", "",  "", "",  "", "", "",  "", "|"};
      {_, true} -> {?IIF(BoxT and BoxL,"┌─",""),  ?IIF(BoxT and BoxR,"─┐",""),
                    ?IIF(BoxL,"├─",""), ?IIF(BoxR,"─┤",""),
                    ?IIF(BoxL,"│ ",""), ?IIF(BoxR," │",""), ?IIF(BoxB and BoxL,"└─",""),
                    ?IIF(BoxB and BoxR,"─┘",""),
                    ?IIF(BoxT,"┬",""),  ?IIF(BoxB,"┴","")};
      {_,false} -> {?IIF(BoxT and BoxL, "+-",""), ?IIF(BoxT and BoxR,"-+",""),
                    ?IIF(BoxL,"+-",""), ?IIF(BoxR,"-+",""),
                    ?IIF(BoxL,"| ",""), ?IIF(BoxR," |",""), ?IIF(BoxB and BoxL,"+-",""),
                    ?IIF(BoxB and BoxR,"-+",""),
                    ?IIF(BoxT, "+",""), ?IIF(BoxB,"+","")}
    end,
  AddSpLn = length([C || C <- Opts#opts.td_sep, C == $\s]),
  AddSpH  = string:copies(Opts#opts.tr_sep, AddSpLn div 2),
  AddSpT  = string:copies(Opts#opts.tr_sep, AddSpLn - (AddSpLn div 2)),
  Row     = fun(Row) ->
              R0 = filter_out([Col(Str, W) || {Str,W} <- lists:zip(Row, Ws)], Exclude),
              [Opts#opts.prefix, BoxBOL, lists:join(Opts#opts.td_sep, R0), BoxEOL, $\n]
            end,
  Header0 = [{string:pad(Str, W, Opts#opts.th_dir), string:copies(Opts#opts.tr_sep, W)}
              || {Str,{_,W}} <- lists:zip(KeyStrs, Ws)],
  Top     = ?IIF(BoxT, [Opts#opts.prefix, BoxTL, lists:join(AddSpH ++ BoxTC ++ AddSpT,
                        filter_out([T || {_,T} <- Header0], Exclude)), BoxTR, $\n], ""),
  Header  = ?IIF(Opts#opts.header,
              [Opts#opts.prefix, BoxBOL,
               lists:join(Opts#opts.td_sep, filter_out([H || {H,_} <- Header0], Exclude)),
               BoxEOL, $\n], ""),
  Delim   = ?IIF(Opts#opts.header,
              [Opts#opts.prefix, BoxBHL, lists:join(AddSpH ++ [Opts#opts.tr_sep_td] ++ AddSpT,
                filter_out([T || {_,T} <- Header0], Exclude)), BoxEHL, $\n], ""),
  Footer  = ?IIF(BoxB,
              [Opts#opts.prefix, BoxBL, lists:join(AddSpH ++ BoxBC ++ AddSpT,
                filter_out([T || {_,T} <- Header0], Exclude)), BoxBR, $\n], ""),
  [Top, Header, Delim, filter_out([Row(R) || R <- Rows], Exclude), Footer].

aligned_format(Fmt, Rows) ->
  {match, FF} = re:run(Fmt, "(~-?s)", [global, {capture, [1], list}]),
  Directions  = [case F of
                   "~s"  -> trailing;
                   "~-s" -> leading
                 end || [F] <- FF],
  Fmt1 = lists:append(string:replace(Fmt, "~-s", "~s", all)),
  aligned_format(Fmt1, Rows, Directions).

aligned_format(Fmt, Rows, Directions) when is_list(Rows), is_list(Directions) ->
  Aligned = align_rows(Rows, Directions),
  Fun     = fun(T) when is_tuple(T) -> tuple_to_list(T);
               (L)                  -> case all_columns_simple(L) of
                                          true  -> [L];
                                          false ->  L
                                       end
            end,
  [io_lib:format(Fmt, Fun(Row)) || Row <- Aligned].

align_rows(Rows) ->
  align_rows(Rows, []).

%%-------------------------------------------------------------------------
%% @doc Align rows of terms by stringifying them to uniform column width.
%%      If some row doesn't need to be aligned, pass its value as a binary.
%% `Options' can be:
%%
%% `Rows' is a list. All rows must have the same arity except if a row is
%%  a binary.
%% `Options' contain:
%% <dl>
%% <dt>{pad, Direction}</dt>
%%     <dd>Column padding direction, where `Direction' is one of `leading',
%%         `trailing', `{Position::integer(), leading|trailing|none}',
%%         `{last, leading|trailing|none}'</dd>
%% <dt>{return, tuple|list}</dt>
%%     <dd>Return result rows as lists or tuples</dd>
%% <dt>{prefix, string()}</dt>
%%     <dd>Prefix first item in each row with this string</dd>
%% <dt>{ignore_empty, boolean()}</dt>
%%     <dd>Don't pad trailing empty columns if this option is true</dd>
%% <dt>{exclude, [integer()]}</dt>
%%     <dd>Exclude given column numbers</dd>
%% </dl>
%% @end
%%-------------------------------------------------------------------------
-spec align_rows(
        Rows    :: [tuple()|binary()|list()],
        Options :: [{pad,    Dir::[trailing|leading|both|
                                   {Pos::integer()|last,
                                     trailing|leading|both|none}]} |
                   {exclude,Cols::[integer()]} |
                   {return, Ret::tuple|list}   |
                   {prefix,       string()}    |
                   {ignore_empty, boolean()}]) ->
       [AlignedRow::tuple()|list()].
align_rows([], _Options) ->
  [];
align_rows(Rows, Options) when is_list(Rows), is_list(Options) ->
  case lists:any(fun(I) -> is_tuple(I) end, Rows) of
    true ->
      FF = fun
            (I) when is_binary(I) -> I;
            (I) when is_tuple(I)  -> tuple_to_list(I);
            (I) when is_list(I)   -> I
           end,
      RR = [FF(R) || R <- Rows],
      LL = align_rows1(RR, Options),
      case proplists:get_value(return, Options) of
        I when I==undefined; I==tuple ->
          [case is_binary(R) of
             true -> binary_to_list(R);
             _    -> list_to_tuple(R)
           end || R <- LL];
        list ->
          [case is_binary(R) of
             true  -> binary_to_list(R);
             false -> R
           end || R <- LL]
      end;
    false ->
      align_rows1(Rows, Options)
  end.
align_rows1(Rows, Options) when is_list(Rows), is_list(Options) ->
  Simple = all_columns_simple(Rows),
  {N, L, Unlist} = if
                     Simple -> {1, [[I] || I <- Rows], true};
                     true   -> {length(hd([R || R <- Rows, not is_binary(R)])), Rows, false}
                   end,
  lists:filter(fun
                (R) when is_list(R)   -> length(R) =/= N;
                (R) when is_binary(R) -> false
               end, L) =/= []
    andalso throw({all_rows_must_have_same_arity, N,
                   [I || I <- L, not is_binary(I), length(I) /= N]}),
  RR  = [case is_binary(R) of
           true -> R;
           _    -> [lists:flatten(element(2,to_string1(I))) || I <- R]
         end || R <- L],
  Ln  = [case is_binary(R) of
           true -> R;
           _    -> list_to_tuple([length(I) || I <- R])
         end || R <- RR],
  Max = fun(I) -> lists:max([case is_binary(R) of
                               true -> 0;
                               _    -> element(I, R)
                             end || R <- Ln]) end,
  ML  = fun
          Loop(0, A) -> A;
          Loop(I, A) -> Loop(I-1, [Max(I) | A])
        end,
  LW  = ML(N, []),
  Dir = proplists:get_value(pad, Options, []),
  DD  = if
          length(Dir) == N, is_atom(hd(Dir)) ->
            Dir;
          true ->
            T0 = erlang:make_tuple(N, trailing),
            {_,T1} = lists:foldl(
                  fun
                    (D, {I, A})     when   D == trailing orelse
                                           D == leading  orelse
                                           D == both     orelse
                                           D == none ->
                      {I+1, setelement(I, A, D)};
                    ({last,D}, {I,A}) when
                                          (D == trailing orelse
                                           D == leading  orelse
                                           D == both     orelse
                                           D == none) ->
                      {I+1, setelement(N, A, D)};
                    ({J,D}, {I, A}) when (is_integer(J) andalso J =< N) andalso
                                          (D == trailing orelse
                                           D == leading  orelse
                                           D == both     orelse
                                           D == none) ->
                      {I+1, setelement(J, A, D)}
                  end, {1, T0}, Dir),
            tuple_to_list(T1)
        end,
  HasLastNone = lists:member({last,none}, Dir),
  IE  = proplists:get_value(ignore_empty, Options, false),
  SkE = fun
          (List) when not IE ->
            List;
          (List) ->
            RL0 = lists:reverse(List),
            RL1 = lists:dropwhile(fun({_Wid, Row, _Pad}) -> Row == [] end, RL0),
            case {length(RL0) == length(RL1), HasLastNone, RL1} of
              {false, true, [{Wid, Row, _} | Rest]} ->
                lists:reverse([{Wid, Row, none} | Rest]);
              _ ->
                lists:reverse(RL1)
            end
        end,
  PAD = fun
          (S,_W, none) -> S;
          (S, W, D)    -> string:pad(S, W, D)
        end,
  LL  = [case is_binary(R) of
          true  -> R;
          false -> [lists:flatten(PAD(S, W, D)) || {W,S,D} <- SkE(lists:zip3(LW,R,DD))]
         end || R <- RR],
  LL1 = case [I || I <- proplists:get_value(exclude, Options, []), is_integer(I), I > 0] of
          [] -> LL;
          EX -> lists:map(fun(LR) ->
                  [I || I <- tuple_to_list(
                              lists:foldl(fun(I, T) ->
                                setelement(I, T, false)
                                end, list_to_tuple(LR), EX)), I =/= false]
                  end, LL)
        end,
  case  proplists:get_value(prefix,  Options, []) of
    [] when Unlist ->
      [I || [I] <- LL1];
    [] ->
      LL1;
    Pfx ->
      LL2 = [case R of
               _ when is_binary(R) ->
                 <<(list_to_binary(Pfx))/binary, R/binary>>;
               [HH|TT] when is_binary(HH) ->
                 [<<(list_to_binary(Pfx))/binary, HH/binary>> | TT];
               [HH|TT] when is_list(HH) ->
                 [Pfx ++ HH | TT]
             end || R <- LL1],
      if
        Unlist -> [I || [I] <- LL2];
        true   -> LL2
      end
  end.

all_columns_simple(Rows) ->
  lists:all(fun(I) ->
             is_atom(I)    orelse
             is_integer(I) orelse
             is_float(I)   orelse
             is_binary(I)  orelse
             io_lib:printable_list(I)
            end, Rows).

take_nth(I, L) when I < 2 -> L;
take_nth(_,[])            -> [];
take_nth(I,[_|T])         -> take_nth(I-1, T).

ws([H|T], Ws)             -> ws(T, ws1(H, Ws));
ws([], Ws)                -> Ws.

ws1([{T,undefined}|Vs], [{Type,Max}|Ms]) ->
  [{type(T,Type),Max}|ws1(Vs,Ms)];
ws1([{T,V}|Vs], [{Type,Max}|Ms]) ->
  [{type(T,Type),max(string:length(V),Max)}|ws1(Vs,Ms)];
ws1([], []) -> [].

type(T, undefined) -> T;
type(T, T)         -> T;
type(_, _)         -> string.

to_strings(Keys, Values, undefined) ->
  to_strings1(Keys, Values, tuple_to_list(erlang:make_tuple(length(Keys), undefined)), #opts{});
to_strings(Keys, Values, #opts{td_formats=undefined}) ->
  to_strings1(Keys, Values, tuple_to_list(erlang:make_tuple(length(Keys), undefined)), #opts{});
to_strings(Keys, Values, #opts{td_formats=Formats}=O) when is_tuple(Formats), tuple_size(Formats) == length(Keys) ->
  to_strings1(Keys, Values, tuple_to_list(Formats), O);
to_strings(Keys, Values, #opts{td_formats=Formats}=O) when (is_list(Formats) andalso length(Formats)==length(Keys))
                                                           orelse is_function(Formats, 1)
                                                           orelse is_function(Formats, 3) ->
  to_strings1(Keys, Values, Formats, O).

to_strings1([], _, _, _) ->
  [];
to_strings1([K|Keys], Map, [F|Formats], Opts) when is_map(Map) ->
  [to_string(K, maps:get(K, Map), Map, F, Opts) | to_strings1(Keys, Map, Formats, Opts)];
to_strings1([K|Keys], Map, Fmt, Opts) when is_map(Map) andalso (is_function(Fmt,1) orelse is_function(Fmt, 3)) ->
  [to_string(K, maps:get(K, Map), Map, Fmt, Opts) | to_strings1(Keys, Map, Fmt, Opts)];
to_strings1(_Keys, List, Fmt, Opts) when is_list(List) andalso (is_function(Fmt,1) orelse is_function(Fmt,3)) ->
  Row = list_to_tuple(List),
  [to_string(undefined, Entry, Row, Fmt, Opts) || Entry <- List];
to_strings1(_Keys, List, Formats, Opts) when is_list(List), is_list(Formats) ->
  Row = list_to_tuple(List),
  [to_string(undefined, Entry, Row, F, Opts) || {Entry, F} <- lists:zip(List, Formats)];
to_strings1(Keys, Row, Fmt, Opts) when is_tuple(Row) andalso (is_function(Fmt,1) orelse is_function(Fmt,3)) ->
  [to_string(K, Entry, Row, Fmt, Opts) || {K, Entry} <- lists:zip(Keys, tuple_to_list(Row))];
to_strings1(Keys, Row, Formats, Opts) when is_tuple(Row), is_list(Formats) ->
  List = tuple_to_list(Row),
  [to_string(K, Entry, Row, F, Opts) || {K, Entry, F} <- lists:zip3(Keys, List, Formats)].

to_string(V) ->
  to_string(undefined, V, undefined, undefined, undefined).

to_string(_K, V, _Row, Fmt, _Opts) when is_list(Fmt) ->
  {guess_type(V), io_lib:format(Fmt, [V])};
to_string(_K, V, _Row, Fmt, Opts) when is_function(Fmt, 1) ->
  Res = Fmt(V),
  to_string2(Res, Opts);
to_string(K, V, Row, Fmt, Opts) when is_function(Fmt, 3) ->
  Res = Fmt(K, V, Row),
  to_string2(Res, Opts);
to_string(_K, V, _Row, undefined, Opts) ->
  to_string1(V, Opts).
  
to_string2({number,R}, Opts) when is_number(R) -> to_string1(R, Opts);
to_string2({number, Dec, I}, Opts) when is_number(Dec) ->
  {number, format_number(I, Dec, Dec, #{thousands=>Opts#opts.thousands, return=>list})};
to_string2({number,L}=R,_Opts) when is_list(L) -> R;
to_string2({string,L},   Opts)                 -> to_string1(L, Opts);
to_string2({ccy,I},Opts)     when is_number(I) -> {number, format_ccy(I, 2, Opts)};
to_string2({ccy,Decimals,I},Opts) when is_integer(Decimals)
                                , is_number(I) -> {number, format_ccy(I, Decimals, Opts)};
to_string2(R,_) when is_tuple(R)               -> throw({invalid_format_function_return, R});
to_string2(R,_) when is_list(R)                -> {string, R}.
  
to_string1(V) ->
  to_string1(V, undefined).

to_string1(I, #opts{thousands=undefined}) when is_number(I) -> to_string1(I, undefined);
to_string1(I, #opts{thousands=Thousands}) when is_number(I) -> to_string1(I, Thousands);
to_string1(I, undefined) when is_integer(I)  -> {number, integer_to_list(I)};
to_string1(I, Thousands) when is_integer(I)
                            ,(is_binary(Thousands) orelse is_list(Thousands))
                                             -> {number, format_integer(I, #{thousands=>Thousands,
                                                                             return=>list})};
to_string1(F, undefined) when is_float(F)    -> {number, io_lib:format("~.4f",[F])};
to_string1(F, Thousands) when is_float(F)
                            ,(is_binary(Thousands) orelse is_list(Thousands))
                                             -> {number, format_number(F, 4,4, #{thousands=>Thousands,
                                                                                 return=>list})};
to_string1(Str,_Opts)   when is_list(Str)    -> {string, Str};
to_string1(Bin,_Opts)   when is_binary(Bin)  -> {string, binary_to_list(Bin)};
to_string1(undefined,_Opts)                  -> {string, ""};
to_string1(T,_Opts)                          -> {string, io_lib:format("~tp", [T])}.

format_ccy(I, Decimals, #opts{ccy_sym=Sym, ccy_sep=Sep, ccy_pos=Pos, thousands=Th}) ->
  format_number(I, Decimals, Decimals, #{ccy_sym=>Sym, ccy_sep=>Sep, ccy_pos=>Pos,
                                         thousands=>Th, return=>list}).

guess_type(V)     when is_integer(V)   -> number;
guess_type(V)     when is_float(V)     -> number;
guess_type(_)                          -> string.

%% @doc Convert format and arguments to binary/list shortening .
%% This function can be used by Elixir, which is missing the equivalent of `io_lib.format/2'
-spec format(binary()|string(), list()) -> binary()|string().
format(Fmt, Args) when is_list(Args) ->
  Res = io_lib:format(Fmt, Args),
  format1(Res).

%% @doc Convert format and arguments to binary/list shortening .
%% This function can be used by Elixir, which is missing the equivalent of `io_lib.format/2'
-spec format_binary(binary()|string(), list()) -> binary().
format_binary(Fmt, Args) when is_binary(Fmt) or is_list(Fmt) ->
  iolist_to_binary(format(Fmt, Args)).

-define(DEFAULT_PRICE_PRECISION, 2).
-define(DEFAULT_PRICE_DECIMALS, 2).
-define(THOUSANDS_SEP, <<"">>).
-define(DECIMAL_POINT, <<".">>).

-spec format_integer(integer()) -> formatted_number().
format_integer(Integer) ->
  format_integer(Integer, #{}).
-spec format_integer(integer(), format_number_opts()) -> formatted_number().
format_integer(Integer, Opts) when is_integer(Integer), is_map(Opts) ->
  do_format_number(Integer, 0, Opts).

%% @doc
%% The same as uef_format:format_number/4 with #{} as the forth argument.
%% @see format_number/4
%% @end
-spec format_number(number(), precision(), decimals()) -> formatted_number().
format_number(N, Precision, Decimals) when (is_float(N) orelse is_integer(N))
                                         ,  is_integer(Precision)
                                         ,  is_integer(Decimals) ->
	format_number(N, Precision, Decimals, #{});
format_number(N, Precision, Opts) when is_map(Opts) ->
  format_number(N, Precision, Precision, Opts).

%% @doc
%% Formats Number by adding thousands separator between each set of 3
%% digits to the left of the decimal point, substituting Decimals for
%% the decimal point, and rounding to the specified Precision.
%% Returns a binary value.
%% @end
-spec format_number(number(), precision(), decimals(), format_number_opts()) ->
        formatted_number().
format_number(Number, Precision, Decimals, Opts) when is_integer(Number) ->
	format_number(erlang:float(Number), Precision, Decimals, Opts);
format_number(Number, Precision, Decimals, Opts) when is_float(Number) ->
	Precision2 = case Precision > 0 andalso Decimals < Precision of
		true  -> Decimals;
		false -> Precision
	end,
	Rounded = round_number(Number, Precision2), % round to Precision2 before formatting
	do_format_number(Rounded, Decimals, Opts).

%% @doc
%% Formats Number in price-like style.
%% Returns a binary containing FormattedPrice formatted with a precision
%% of 2 and decimal digits of 2. The same as format_price/2 with a precision
%% of 2 as the second argument. See uef_format:format_price/2 docs.
%% @end
-spec format_price(Number:: number()) -> FormattedPrice :: formatted_number().
format_price(Price) ->
	format_price(Price, ?DEFAULT_PRICE_PRECISION).

%% @doc
%% Formats Number in price-like style.
%% Returns a binary containing FormattedPrice formatted with a specified
%% precision as the second argument and decimal digits of 2.
%% The same as uef_format:format_price/3 with #{} as the third argument.
%% @see format_price/3
%% @end
-spec format_price(Number::number(), Precision::precision()) -> formatted_number().
format_price(Price, Precision) ->
	format_price(Price, Precision, #{}).

%% format_price/3
-spec format_price(Number::number(), Precision::precision(),
        CcySymbol_OR_Options::format_number_opts() | ccy_sym()) ->
          FormattedPrice::formatted_number().
%% @doc
%% Formats Number in price-like style.
%% Returns a binary containing FormattedPrice formatted with a specified
%% precision as the second argument, decimal digits of 2,
%% and with ccy symbol (or options) as the third argument.
%% If CcySymbol_OR_Options is a map the functions works as format_number/4
%% with decimal digits of 2 as the third argument and with options as the forth one.
%% If CcySymbol_OR_Options is a binary or a string, the corresponding
%% ccy symbol is added to the left.
%% @end
format_price(Price, Precision, Opts) when is_map(Opts) ->
	format_number(Price, Precision, ?DEFAULT_PRICE_DECIMALS, Opts);
format_price(Price, Precision, CurSymbol) when is_binary(CurSymbol) orelse is_list(CurSymbol) ->
	format_number(Price, Precision, ?DEFAULT_PRICE_DECIMALS, #{ccy_sym => CurSymbol});
format_price(Price, Precision, Opts) ->
	erlang:error({badarg, Opts}, [Price, Precision, Opts]).

%% round_price/1
-spec round_price(Number :: number()) -> float().
%% @doc Rounds the number to the precision of 2.
round_price(Price) -> round_number(Price, 2).

%% round_number/2
-spec round_number(Number :: number(), Precision :: integer()) -> float().
%% @doc Rounds the number to the specified precision.
round_number(Number, Precision) ->
	P = math:pow(10, Precision),
	erlang:round(Number * P) / P.

%%%------------------------------------------------------------------------------
%%%   Internal functions
%%%------------------------------------------------------------------------------

till_quote([$\\, $' | T], Acc) -> till_quote(T, [$', $\\ | Acc]);
till_quote([$'      | T], Acc) -> {lists:reverse(Acc), T};
till_quote([C       | T], Acc) -> till_quote(T, [C | Acc]);
till_quote([],            Acc) -> {lists:reverse(Acc), []}.

append_after_next_word([32|T],S,false)    -> [S, 32 | T];
append_after_next_word([32|T],S,true)     -> [32 | append_after_next_word(T,S,true)];
append_after_next_word([$'|T],S,false)    -> [$' | append_after_next_word(T,S,true)];
append_after_next_word([$\\,$'|T],S,true) -> [$\\,$' | append_after_next_word(T,S,true)];
append_after_next_word([$'|T],S,true)     -> [$',S | T];
append_after_next_word([$"|T],S,false)    -> [$" | append_after_next_word(T,S,true)];
append_after_next_word([$\\,$"|T],S,true) -> [$\\,$' | append_after_next_word(T,S,true)];
append_after_next_word([$"|T],S,true)     -> [$",S | T];
append_after_next_word([H|T],S,X)         -> [H, 32 | append_after_next_word(T, S, X)];
append_after_next_word([],S,_)            -> S.

format1(["'Elixir."++R|T])        -> {S,T1} = till_quote(R, []), [[S], format1(T1) | format1(T)];
format1([$'|T])                   -> {S,T1} = till_quote(T, []), [[S], format1(T1)];
format1("#{__struct__ => " ++ T)  -> T1 = string:trim(T, leading),
                                     T2 = append_after_next_word(T1, ${, false),
                                     format1(T2);
format1([H|T]) when is_integer(H) -> [H          | format1(T)];
format1([H|T]) when is_list(H)    -> [format1(H) | format1(T)];
format1([H|T])                    -> [H          | format1(T)];
format1([])                       -> [].

%% do_format_number/3
-spec do_format_number(number(), decimals(), format_number_opts()) -> formatted_number().
do_format_number(Number, Decimals, Opts) when is_float(Number), is_integer(Decimals), is_map(Opts) ->
	PositiveNumber = case Number < 0 of
		false -> Number;
		true  -> erlang:abs(Number)
	end,
	BinNum = erlang:float_to_binary(PositiveNumber, [{decimals, Decimals}]),
	{IntegerPart, DecimalPart} = case binary:split(BinNum, <<".">>) of
		[I, D] -> {I, D}; % ex: <<"12.345">> -> [<<"12">>, <<"345">>]
		[I] -> {I, <<>>} % ex: <<"12345">> -> [<<"12345">>] (when Precision < 1)
	end,
  do_format_num(Number, IntegerPart, DecimalPart, Opts);

do_format_number(Number, _, Opts) when is_integer(Number) ->
  do_format_num(Number, integer_to_binary(Number), <<>>, Opts).

do_format_num(Number, IntegerPart, DecimalPart, Opts) when is_map(Opts) ->
	HeadSize = erlang:byte_size(IntegerPart) rem 3,
	<<Head:HeadSize/binary, IntRest/binary>> = IntegerPart, % ex: <<"12", "345678">> = <<"12345678">>
	ThousandParts = split_thousands(IntRest), % ex: <<"345678">> -> [<<"345">>, <<678>>]
	AllIntegerParts = case HeadSize > 0 of
		true  -> [Head|ThousandParts];
		false -> ThousandParts
	end,
	ThousandsSep = maybe_to_binary(maps:get(thousands, Opts, ?THOUSANDS_SEP)),
	% Join with thousands separator
	FormattedIntegerPart = <<(binary_join(AllIntegerParts, ThousandsSep))/binary>>,
	PositiveFormattedNumber = case DecimalPart of
		<<>> ->
			FormattedIntegerPart;
		_    ->
			DecimalPoint = maybe_to_binary(maps:get(decimal_point, Opts, ?DECIMAL_POINT)),
			<<FormattedIntegerPart/binary, DecimalPoint/binary, DecimalPart/binary>>
	end,
	% Insert "-" before number if negative
	FormattedNumber1 = case Number < 0 of
		false -> PositiveFormattedNumber;
		true  -> <<"-", PositiveFormattedNumber/binary>>
	end,
	% Format with ccy options
	format_number_with_ccy(FormattedNumber1, Opts).

%% format_number_with_ccy/2
-spec format_number_with_ccy(binary(), map()) -> binary().
format_number_with_ccy(FmtNum, #{ccy_sym := CurSymbol0} = Opts) ->
	CurSym = maybe_to_binary(CurSymbol0),
	CurSep = maybe_to_binary(maps:get(ccy_sep, Opts, <<"">>)),
  format_number_return(
    case maps:get(ccy_pos, Opts, left) of
      left -> <<CurSym/binary, CurSep/binary, FmtNum/binary>>;
      _ -> <<FmtNum/binary, CurSep/binary, CurSym/binary>>
    end,
    maps:get(return, Opts, binary));
format_number_with_ccy(FmtNum, Opts) ->
	format_number_return(FmtNum, maps:get(return, Opts, binary)).

format_number_return(Bin, binary) when is_binary(Bin) ->
  Bin;
format_number_return(Bin, list) when is_binary(Bin) ->
  binary_to_list(Bin);
format_number_return(Bin, list) when is_list(Bin) ->
  Bin;
format_number_return(_Bin, Type) ->
  erlang:error({badarg, {return, Type}}).

%% maybe_to_binary/1
-spec maybe_to_binary(binary() | string()) -> binary().
maybe_to_binary(B) when is_binary(B) -> B;
maybe_to_binary(L) when is_list(L) ->
	case unicode:characters_to_binary(L, utf8, utf8) of
		B when is_binary(B) -> B;
		_ -> erlang:error({badarg, L})
	end;
maybe_to_binary(T)->
	erlang:error({badarg, T}).

%% split_thousands/1
-spec split_thousands(binary()) -> [<<_:24>>].
split_thousands(Bin) ->
	split_thousands(Bin, []).

%% split_thousands/2
-spec split_thousands(binary(), [<<_:24>>]) -> [<<_:24>>].
split_thousands(<<>>, List) ->
	lists:reverse(List);
split_thousands(Bin, List) ->
	<<B:3/binary, Rest/binary>> = Bin,
	split_thousands(Rest, [B | List]).

binary_join([], _Sep) -> <<>>;
binary_join([Bin], _Sep) -> Bin;
binary_join([Head|Tail], Sep) ->
	lists:foldl(fun(Value, Acc) ->
		<<Acc/binary, Sep/binary, Value/binary>>
	end, Head, Tail).

%% @doc Parse a given CSV file.
-spec parse_csv(string()) -> [[string()]].
parse_csv(File) when is_list(File) ->
  csv:parse(File).

%% @doc Parse a given CSV file.
-spec parse_csv(string(), [fix_lengths | {open, Opts::list()}]) -> [[string()]].
parse_csv(File, Opts) when is_list(File), is_list(Opts) ->
  csv:parse(File, Opts).

%% @doc Split a list into batches of N items
-spec batch_split(integer(), list()) -> [list()].
batch_split(N, L) when is_integer(N), is_list(L) ->
  batch_split(N, N, L, [], []).
  
batch_split(_, _, [], A, Out) ->
  lists:reverse([lists:reverse(A) | Out]);
batch_split(N, 0, L, A, Out) ->
  batch_split(N, N, L, [], [lists:reverse(A) | Out]);
batch_split(N, I, [H|T], A, Out) ->
  batch_split(N, I-1, T, [H|A], Out).

to_outline(none) -> #{top=>false,bottom=>false,left=>false,right=>false};
to_outline(full) -> #{top=>true, bottom=>true, left=>true, right=>true};
to_outline(L) when is_list(L) ->
  (L -- [top,bottom,left,right] /= []) andalso erlang:error({invalid_outline, L}),
  #{top   =>lists:member(top,   L),
    bottom=>lists:member(bottom,L),
    left  =>lists:member(left,  L),
    right =>lists:member(right, L)};
to_outline(#{} = M) ->
  #{top   =>maps:get(top,   M, false),
    bottom=>maps:get(bottom,M, false),
    left  =>maps:get(left,  M, false),
    right =>maps:get(right, M, false)}.

%%--------------------------------------------------------------------
%% Tests
%%--------------------------------------------------------------------
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").

word_wrap_test() ->
  ?assertEqual(["abc,efg,","exdf"],
    stringx:wordwrap(["abc", "efg", "exdf"], 8, ",")).

align_rows_test() ->
  ?assertEqual(
    [{"cc    ","x1"},{"'Done'","x2"},{"xx    ","x3"}],
    stringx:align_rows([{a, 10, cc,x1}, {bxxx, 200.00123, 'Done',x2},
                        {abc, 100.0, xx,x3}],
                       [{exclude, [1,2]}])

  ),
  ?assertEqual(
    [{"x1"},{"x2"},{"x3"}],
    stringx:align_rows([{a, 10, cc,x1}, {bxxx, 200.00123, 'Done',x2},
                        {abc, 100.0, xx,x3}], [{exclude, [1,2,3]}])
  ),
  ?assertEqual(
    [{"a   ","10      ","cc    ","x1"},
     {"bxxx","200.0012","'Done'","x2"},
     {"abc ","100.0000","xx    ","x3"}],
    stringx:align_rows([{a, 10, cc,x1}, {bxxx, 200.00123, 'Done',x2},
                        {abc, 100.0, xx,x3}], [])
  ).

pretty_table_test() ->
  ?assertEqual(
    " a   |     b    \n"
    "-----+----------\n"
    " a   |        10\n"
    "bxxx | 200.00123\n"
    "-----+----------\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c,d},
      [{a, 10, cc,x1}, {bxxx, 200.00123, 'Done',x2}, {abc, 100.0, xx,x3}],
      #opts{td_dir=both, td_exclude=[3,4],
        td_formats={
          undefined,
          fun(V) when is_integer(V) -> {number, integer_to_list(V)};
             (V) when is_float(V)   -> {number, float_to_list(V, [{decimals, 5}])} end,
          "~w",
          undefined}}))).

pretty_table_unicode_test() ->
  ?assertEqual(
    "a  | b  |  c  \n"
    "---+----+-----\n"
    "10 | 20 |    0\n"
    "30 | 40 | 1000\n"
    "---+----+-----\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => false}))),
  ?assertEqual(
    "a  │ b  │  c  \n"
    "───┼────┼─────\n"
    "10 │ 20 │    0\n"
    "30 │ 40 │ 1000\n"
    "───┴────┴─────\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true}))),
  ?assertEqual(
    "───┬────┬─────\n"
    "a  │ b  │  c  \n"
    "───┼────┼─────\n"
    "10 │ 20 │    0\n"
    "30 │ 40 │ 1000\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => [top]}))),
  ?assertEqual(
    "a  │ b  │  c  \n"
    "───┼────┼─────\n"
    "10 │ 20 │    0\n"
    "30 │ 40 │ 1000\n"
    "───┴────┴─────\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => [bottom]}))),
  ?assertEqual(
    "│ a  │ b  │  c  \n"
    "├────┼────┼─────\n"
    "│ 10 │ 20 │    0\n"
    "│ 30 │ 40 │ 1000\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => [left]}))),
  ?assertEqual(
    "a  │ b  │  c   │\n"
    "───┼────┼──────┤\n"
    "10 │ 20 │    0 │\n"
    "30 │ 40 │ 1000 │\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => [right]}))),
  ?assertEqual(
    "───┬────┬─────\n"
    "a  │ b  │  c  \n"
    "───┼────┼─────\n"
    "10 │ 20 │    0\n"
    "30 │ 40 │ 1000\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => [top]}))),
  ?assertEqual(
    "┌────┬────┬──────┐\n"
    "│ a  │ b  │  c   │\n"
    "├────┼────┼──────┤\n"
    "│ 10 │ 20 │    0 │\n"
    "│ 30 │ 40 │ 1000 │\n"
    "└────┴────┴──────┘\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c}, [{10, 20, 0}, {30, 40, 1000}], #{unicode => true, outline => full}))).

pretty_table_ccy_thousands_test() ->
  ?assertEqual(
    " a   |    b     |  c  \n"
    "-----+----------+-----\n"
    "$ 10 | 1,000.00 |    1\n"
    " $ 2 | 1,123.56 | 1000\n"
    "-----+----------+-----\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c},
      [{10, 1000.0, 1}, {2, 1123.56, 1000}],
      #opts{td_dir=both, thousands=",", ccy_sym="$", ccy_sep=" ",
        td_formats=
          fun(a,V,_) when is_integer(V) -> {ccy, 0, V};
             (_,V,_) when is_integer(V) -> {number, integer_to_list(V)};
             (_,V,_) when is_float(V)   -> {number, 2, V} end
          }))
  ).

pretty_table_calc_format_test() ->
  ?assertEqual(
    "a  | b  | c \n"
    "---+----+---\n"
    "10 | 20 | 30\n"
    "30 | 40 | 70\n"
    "---+----+---\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c},
      [{10, 20, 0}, {30, 40, 0}],
      #opts{td_dir=both,
        td_formats={
          undefined,
          undefined,
          fun(c,V,Row) when is_integer(V) -> {number, integer_to_list(lists:sum(tuple_to_list(Row)))};
             (_,V,_)   when is_integer(V) -> {number, integer_to_list(V)} end
        }}))).

pretty_table_map_opts_test() ->
  ?assertEqual(
    " a   |     b    \n"
    "-----+----------\n"
    " a   |        10\n"
    "bxxx | 200.00123\n"
    "-----+----------\n",
    lists:flatten(stringx:pretty_table(
      {a,b,c,d},
      [{a, 10, cc,x1}, {bxxx, 200.00123, 'Done',x2}, {abc, 100.0, xx,x3}],
      #{td_dir => both, td_exclude => [3,4], td_formats => {
          undefined,
          fun(V) when is_integer(V) -> {number, integer_to_list(V)};
             (V) when is_float(V)   -> {number, float_to_list(V, [{decimals, 5}])} end,
          "~w",
          undefined}}))).

format_number_test_() -> [
	?_assertEqual(<<"1.00">>, format_number(1, 2, 2, #{})),
	?_assertEqual(<<"1.99">>, format_number(1.99, 2, 2, #{})),
	?_assertEqual(<<"2.00">>, format_number(1.99, 1, 2, #{})),
	?_assertEqual(<<"1">>,    format_integer(1)),
	?_assertEqual(<<"1,000">>,format_integer(1000, #{thousands => <<",">>})),
	?_assertEqual(<<"2,000,000">>,    format_integer(2000000, #{thousands => <<",">>})),
	?_assertEqual(<<"1 000 999.00">>, format_number(1000999, 2, 2, #{thousands => <<" ">>})),
	?_assertEqual(<<"2,000,000.00">>, format_number(2000000, 2, 2, #{thousands => <<",">>})),
	?_assertEqual(<<"9 999 999 999">>, format_integer(9999999999, #{thousands => <<" ">>})),
	?_assertEqual(<<"9 999 999 999.00">>, format_number(9999999999, 2, 2, #{thousands => <<" ">>})),
	?_assertEqual(<<"99 999 999 999.99">>, format_price(99999999999.99, 2, #{thousands => <<" ">>})),
	?_assertEqual(<<"999 999 999 999.99">>, format_price(999999999999.99, 2, #{thousands => <<" ">>})),
	?_assertEqual(<<"999,999,999,999.99">>, format_price(999999999999.99,  2, #{thousands => <<",">>})),
	?_assertEqual(<<"USD 1,234,567,890==4600">>,
    format_number(1234567890.4567, 2, 4, #{thousands => ",",
      decimal_point => "==", ccy_sym => "USD", ccy_sep => " ", ccy_pos => left})),
	?_assertEqual(<<"$1000.88">>, format_price(1000.8767, 4, "$")),
	?_assertEqual(<<"1000.88 руб."/utf8>>, format_price(1000.8767, 4,
    #{ccy_sym => <<"руб."/utf8>>, ccy_sep => " ", ccy_pos => right})),
	?_assertEqual(<<"1000.88 руб."/utf8>>, format_price(1000.8767, 4,
    #{ccy_sym => "руб.", ccy_sep => " ", ccy_pos => right})),
	?_assertEqual(<<"€€1000.00"/utf8>>, format_price(1000, 4,
    #{ccy_sym => "€", ccy_sep => "€", ccy_pos => left})),
	?_assertEqual(format_number(100, 2, 3), format_number(100, 2, 3, #{})),
	?_assertEqual(format_price(1000), format_price(1000, 2)),
	?_assertEqual(format_price(1000), format_price(1000, 2, <<>>)),
	?_assertEqual(format_price(1000), format_number(1000, 2, 2, #{}))
].

round_number_test_() ->	[
	?_assertEqual(1.0,     round_price(1)),
	?_assertEqual(1.01,    round_price(1.01)),
	?_assertEqual(1.01,    round_price(1.015)),
	?_assertEqual(1.02,    round_price(1.025)),
	?_assertEqual(1.02,    round_price(1.0155)),
	?_assertEqual(1.015,   round_number(1.015, 3)),
	?_assertEqual(2.0,     round_number(1.9999, 1)),
	?_assertEqual(2.0,     round_number(1.9999, 2)),
	?_assertEqual(1.9999,  round_number(1.9999, 4)),
	?_assertEqual(-1.9999, round_number(-1.9999, 4)),
	?_assertEqual(-2.0,    round_number(-1.9999, 3)),
	?_assertEqual(10000.0, round_number(9999.999999, 5))
].
-endif.