%% Copyright (c) 2012-2020 Plataformatec
%% Copyright (c) Meta Platforms, Inc. and affiliates.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

%   A set of functions for creating and manipulating algebra
%   documents.

%   This module implements the functionality described in
%   ["Strictly Pretty" (2000) by Christian Lindig][0] with small
%   additions, like support for binary nodes and a break mode that
%   maximises use of horizontal space.

%   The functions `nest/2`, `space/2` and `line/2` help you put the
%   document together into a rigid structure. However, the document
%   algebra gets interesting when using functions like `break/3` and
%   `group/1`. A break inserts a break between two documents. A group
%   indicates a document that must fit the current line, otherwise
%   breaks are rendered as new lines.

%   ## Implementation details

%   The implementation of `Inspect.Algebra` is based on the Strictly Pretty
%   paper by [Lindig][0] which builds on top of previous pretty printing
%   algorithms but is tailored to strict languages, such as Erlang.
%   The core idea in the paper is the use of explicit document groups which
%   are rendered as flat (breaks as spaces) or as break (breaks as newlines).

%   This implementation provides two types of breaks: `strict` and `flex`.
%   When a group does not fit, all strict breaks are treated as newlines.
%   Flex breaks however are re-evaluated on every occurrence and may still
%   be rendered flat. See `break/1` and `flex_break/1` for more information.

%   This implementation also adds `force_breaks/0` and `next_break_fits/2` which
%   give more control over the document fitting.

%     [0]: http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.34.2200

-module(erlfmt_algebra).

-dialyzer(no_improper_lists).

-define(newline, <<"\n">>).

-export_type([doc/0, append_fun/0]).

-export([
    force_breaks/0,
    empty/0,
    string/1,
    concat/1,
    concat/2,
    concat/3,
    nest/2,
    nest/3,
    break/0,
    break/1,
    next_break_fits/1,
    next_break_fits/2,
    flex_break/0,
    flex_break/1,
    flex_break/2,
    flex_break/3,
    break/2,
    break/3,
    group/1,
    space/2,
    line/0,
    line/1,
    line/2,
    fold_doc/2,
    format/2
]).

% Functional interface to "doc" records

% doc_string represents Literal text, which is simply printed as is.
-record(doc_string, {
    string :: unicode:chardata(),
    length :: non_neg_integer()
}).

% doc_line should be thought of as a space character which may be replaced by a line break when necessary.
-record(doc_line, {
    count :: integer()
}).

-record(doc_cons, {
    left :: doc(),
    right :: doc()
}).

% In doc_nest, all breaks inside the field `doc` that are printed as newlines are followed by `indent` spaces.
% `always_or_break` was not part of the original paper.
% `always` means nesting always happen,
% `break` means nesting only happens inside a group that has been broken.
-record(doc_nest, {
    doc :: doc(),
    indent :: non_neg_integer(),
    always_or_break :: always | break
}).

% The decision for each group affects all the line breaks of a group at a whole but is made for subgroups individually.
% 1. Print every optional line break of the current group and all its subgroups as spaces. If
% the current group then fits completely into the remaining space of current line this is
% the layout of the group.
% 2. If the former fails every optional line break of the current group is printed as a newline.
% Subgroups and their line breaks, however, are considered individually as they are reached
% by the pretty printing process.
-record(doc_group, {
    group :: doc()
}).

-record(doc_break, {
    break :: binary(),
    flex_or_strict :: flex | strict
}).

-record(doc_fits, {
    group :: doc(),
    enabled_or_disabled :: enabled | disabled
}).

% The first six constructors are described in the original paper at "Figure 1: Six constructors for the doc data type".
% doc_break is added as part of the implementation in section 3.
% doc_fits and doc_force_breaks are newly added.
% doc_force_breaks is used:
%  - to preserve the users' intention of a newline by breaking up the group or
%  - to break things when a comment is present and they have to be broken up
-type doc() ::
    binary()
    | doc_force_breaks
    | doc_nil
    | #doc_string{}
    | #doc_line{}
    | #doc_cons{}
    | #doc_nest{}
    | #doc_group{}
    | #doc_break{}
    | #doc_fits{}.

-define(is_doc(Doc),
    ((Doc == doc_force_breaks) orelse
        (Doc == doc_nil) orelse
        is_binary(Doc) orelse
        is_record(Doc, doc_line) orelse
        is_record(Doc, doc_break) orelse
        is_record(Doc, doc_cons) orelse
        is_record(Doc, doc_fits) orelse
        is_record(Doc, doc_group) orelse
        is_record(Doc, doc_nest) orelse
        is_record(Doc, doc_string))
).

-type append_fun() :: fun((doc(), doc()) -> doc()).

% empty is not printed at all, but it is essential to implement optional output: if ... then "output" else empty;
% empty is mapped to the empty string by the pretty printer.
-spec empty() -> doc().
empty() -> doc_nil.

% string documents are measured in terms of graphemes towards the document size.
-spec string(unicode:chardata()) -> doc().
string(String) ->
    #doc_string{string = String, length = string:length(String)}.

% Concatenates two document entities returning a new document.
-spec concat(doc(), doc()) -> doc().
concat(Left, Right) when is_binary(Left), is_binary(Right) ->
    #doc_string{string = [Left | Right], length = byte_size(Left) + byte_size(Right)};
concat(#doc_string{string = String, length = Length}, Right) when is_binary(Right) ->
    #doc_string{string = [String | Right], length = Length + byte_size(Right)};
concat(Left, #doc_string{string = String, length = Length}) when is_binary(Left) ->
    #doc_string{string = [Left | String], length = Length + byte_size(Left)};
concat(#doc_string{} = Left, #doc_string{} = Right) ->
    #doc_string{
        string = [Left#doc_string.string | Right#doc_string.string],
        length = Left#doc_string.length + Right#doc_string.length
    };
concat(Left, Right) when ?is_doc(Left), ?is_doc(Right) ->
    #doc_cons{left = Left, right = Right}.

% Concatenates a list of documents returning a new document.
-spec concat([doc()]) -> doc().
concat(Docs) when is_list(Docs) ->
    fold_doc(fun concat/2, Docs).

% Concatenates three document entities returning a new document.
-spec concat(doc(), doc(), doc()) -> doc().
concat(A, B, C) when ?is_doc(A), ?is_doc(B), ?is_doc(C) ->
    concat(A, concat(B, C)).

% Nests the given document at the given `level`.
-spec nest(doc(), non_neg_integer()) -> doc().
nest(Doc, Level) ->
    nest(Doc, Level, always).

-spec nest(doc(), non_neg_integer(), always | break) -> doc().
nest(Doc, 0, _Mode) when ?is_doc(Doc) ->
    Doc;
nest(Doc, Indent, always) when ?is_doc(Doc), is_integer(Indent) andalso Indent >= 0 ->
    #doc_nest{doc = Doc, indent = Indent, always_or_break = always};
nest(Doc, Indent, break) when ?is_doc(Doc), is_integer(Indent) andalso Indent >= 0 ->
    #doc_nest{doc = Doc, indent = Indent, always_or_break = break}.

% This break can be rendered as a linebreak or as the given `string`, depending on the `mode` or line limit of the chosen layout.
-spec break() -> doc().
break() ->
    break(<<" ">>).

-spec break(binary()) -> doc().
break(String) when is_binary(String) ->
    #doc_break{break = String, flex_or_strict = strict}.

%   Considers the next break as fit.

%   `mode` can be `:enabled` or `:disabled`. When `:enabled`,
%   it will consider the document as fit as soon as it finds
%   the next break, effectively cancelling the break. It will
%   also ignore any `force_breaks/0` in search of the next break.

%   When disabled, it behaves as usual and it will ignore
%   any further `next_break_fits/2` instruction.

%   ## Examples

%   This is used by Elixir's code formatter to avoid breaking
%   code at some specific locations. For example, consider this
%   code:

%       some_function_call(%{..., key: value, ...})

%   Now imagine that this code does not fit its line. The code
%   formatter introduces breaks inside `(` and `)` and inside
%   `%{` and `}`. Therefore the document would break as:

%       some_function_call(
%         %{
%           ...,
%           key: value,
%           ...
%         }
%       )

%   The formatter wraps the algebra document representing the
%   map in `next_break_fits/1` so the code is formatted as:

%       some_function_call(%{
%         ...,
%         key: value,
%         ...
%       })

-spec next_break_fits(doc()) -> doc().
next_break_fits(Doc) ->
    next_break_fits(Doc, enabled).

-spec next_break_fits(doc(), enabled | disabled) -> doc().
next_break_fits(Doc, Mode) when ?is_doc(Doc), Mode == enabled orelse Mode == disabled ->
    #doc_fits{group = Doc, enabled_or_disabled = Mode}.

% Forces the parent group and its parent groups to break.
-spec force_breaks() -> doc().
force_breaks() ->
    doc_force_breaks.

%   Returns a flex break document based on the given `string`.

%   A flex break still causes a group to break, like `break/1`,
%   but it is re-evaluated when the documented is rendered.

%   For example, take a group document represented as `[1, 2, 3]`
%   where the space after every comma is a break. When the document
%   above does not fit a single line, all breaks are enabled,
%   causing the document to be rendered as:

%       [1,
%        2,
%        3]

%   However, if flex breaks are used, then each break is re-evaluated
%   when rendered, so the document could be possible rendered as:

%       [1, 2,
%        3]

%   Hence the name "flex". they are more flexible when it comes
%   to the document fitting. On the other hand, they are more expensive
%   since each break needs to be re-evaluated.

-spec flex_break() -> doc().
flex_break() -> flex_break(<<" ">>).

-spec flex_break(binary()) -> doc().
flex_break(String) when is_binary(String) ->
    #doc_break{break = String, flex_or_strict = flex}.

%   Breaks two documents (`doc1` and `doc2`) inserting a
%   `flex_break/1` given by `break_string` between them.

-spec flex_break(doc(), doc()) -> doc().
flex_break(Doc1, Doc2) ->
    flex_break(Doc1, <<" ">>, Doc2).

-spec flex_break(doc(), binary(), doc()) -> doc().
flex_break(Doc1, BreakString, Doc2) when is_binary(BreakString) ->
    concat(Doc1, flex_break(BreakString), Doc2).

%   Breaks two documents (`doc1` and `doc2`) inserting the given
%   break `break_string` between them.

%   For more information on how the break is inserted, see `break/1`.

%   ## Examples

%       iex> doc = Inspect.Algebra.break("hello", "world")
%       iex> Inspect.Algebra.format(doc, 80)
%       ["hello", " ", "world"]

%       iex> doc = Inspect.Algebra.break("hello", "\t", "world")
%       iex> Inspect.Algebra.format(doc, 80)
%       ["hello", "\t", "world"]

-spec break(doc(), doc()) -> doc().
break(Doc1, Doc2) ->
    break(Doc1, <<" ">>, Doc2).

-spec break(doc(), binary(), doc()) -> doc().
break(Doc1, BreakString, Doc2) when is_binary(BreakString) ->
    concat(Doc1, break(BreakString), Doc2).

%   Returns a group containing the specified document `doc`.

%   Documents in a group are attempted to be rendered together
%   to the best of the renderer ability.

%   The group mode can also be set to `:inherit`, which means it
%   automatically breaks if the parent group has broken too.

%   ## Examples

%       iex> doc =
%       ...>   Inspect.Algebra.group(
%       ...>     Inspect.Algebra.concat(
%       ...>       Inspect.Algebra.group(
%       ...>         Inspect.Algebra.concat(
%       ...>           "Hello,",
%       ...>           Inspect.Algebra.concat(
%       ...>             Inspect.Algebra.break(),
%       ...>             "A"
%       ...>           )
%       ...>         )
%       ...>       ),
%       ...>       Inspect.Algebra.concat(
%       ...>         Inspect.Algebra.break(),
%       ...>         "B"
%       ...>       )
%       ...>     )
%       ...>   )
%       iex> Inspect.Algebra.format(doc, 80)
%       ["Hello,", " ", "A", " ", "B"]
%       iex> Inspect.Algebra.format(doc, 6)
%       ["Hello,", "\n", "A", "\n", "B"]

-spec group(doc()) -> doc().
group(Doc) ->
    #doc_group{group = Doc}.

%   Inserts a mandatory single space between two documents.

%   ## Examples

%       iex> doc = Inspect.Algebra.space("Hughes", "Wadler")
%       iex> Inspect.Algebra.format(doc, 5)
%       ["Hughes", " ", "Wadler"]

-spec space(doc(), doc()) -> doc().
space(Doc1, Doc2) ->
    concat(Doc1, <<" ">>, Doc2).

% A mandatory linebreak, but in the paper doc_line was described as optional? (is this mandatory or optional in this implementation)
% A group with linebreaks will fit if all lines in the group fit.
-spec line() -> doc().
line() -> #doc_line{count = 1}.

-spec line(pos_integer()) -> doc().
line(Count) when is_integer(Count), Count > 0 -> #doc_line{count = Count}.

% Inserts a mandatory linebreak between two documents.
-spec line(doc(), doc()) -> doc().
line(Doc1, Doc2) -> concat(Doc1, line(), Doc2).

%   Folds a list of documents into a document using the given folder function.
%   The list of documents is folded "from the right"; in that, this function is
%   similar to `List.foldr/3`, except that it doesn't expect an initial
%   accumulator and uses the last element of `docs` as the initial accumulator.
%   Example:
%   ```
%   Docs = ["A", "B", "C"],
%   FoldedDocs = fold_doc(fun(Doc, Acc) -> concat([Doc, "!", Acc]) end, Docs),
%   io:format("~p", [FoldedDocs]).
%   ```
%   ["A", "!", "B", "!", "C"]
-spec fold_doc(fun((doc(), doc()) -> doc()), [doc()]) -> doc().
fold_doc(_Fun, []) ->
    empty();
fold_doc(_Fun, [Doc]) ->
    Doc;
fold_doc(Fun, [Doc | Docs]) ->
    Fun(Doc, fold_doc(Fun, Docs)).

% Formats a given document for a given width.
% Takes the maximum width and a document to print as its arguments
% and returns an string representation of the best layout for the
% document to fit in the given width.
% The document starts flat (without breaks) until a group is found.
-spec format(doc(), non_neg_integer() | infinity) -> unicode:chardata().
format(Doc, Width) when ?is_doc(Doc) andalso (Width == infinity orelse Width >= 0) ->
    format(Width, 0, [{0, flat, Doc}]).

%   Type representing the document mode to be rendered
%
%     * flat - represents a document with breaks as flats (a break may fit, as it may break)
%     * break - represents a document with breaks as breaks (a break always fits, since it breaks)
%
%   The following modes are exclusive to fitting
%
%     * flat_no_break - represents a document with breaks as flat not allowed to enter in break mode
%     * break_no_flat - represents a document with breaks as breaks not allowed to enter in flat mode

-type mode() :: flat | flat_no_break | break | break_no_flat.

-spec fits(Width :: integer(), Column :: integer(), HasBreaks :: boolean(), Entries) ->
    boolean()
when
    Entries :: maybe_improper_list(
        {integer(), mode(), doc()},
        {tail, boolean(), Entries} | []
    ).
% We need at least a break to consider the document does not fit since a
% large document without breaks has no option but fitting its current line.
%
% In case we have groups and the group fits, we need to consider the group
% parent without the child breaks, hence {:tail, b?, t} below.

fits(Width, Column, HasBreaks, _) when Column > Width andalso HasBreaks ->
    false;
fits(_, _, _, []) ->
    true;
fits(Width, Column, _, {tail, HasBreaks, Entries}) ->
    fits(Width, Column, HasBreaks, Entries);
%   ## Flat no break

fits(Width, Column, HasBreaks, [
    {Indent, _, #doc_fits{group = X, enabled_or_disabled = disabled}}
    | T
]) ->
    fits(Width, Column, HasBreaks, [{Indent, flat_no_break, X} | T]);
fits(Width, Column, HasBreaks, [{Indent, flat_no_break, #doc_fits{group = X}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent, flat_no_break, X} | T]);
%   ## Breaks no flat

fits(Width, Column, HasBreaks, [
    {Indent, _, #doc_fits{group = X, enabled_or_disabled = enabled}}
    | T
]) ->
    fits(Width, Column, HasBreaks, [{Indent, break_no_flat, X} | T]);
fits(Width, Column, HasBreaks, [{_Indent, break_no_flat, doc_force_breaks} | T]) ->
    fits(Width, Column, HasBreaks, T);
fits(_, _, _, [{_, break_no_flat, #doc_break{}} | _]) ->
    true;
fits(_, _, _, [{_, break_no_flat, #doc_line{}} | _]) ->
    true;
%   ## Breaks

fits(_, _, _, [{_, break, #doc_break{}} | _]) ->
    true;
fits(_, _, _, [{_, break, #doc_line{}} | _]) ->
    true;
fits(Width, Column, HasBreaks, [{Indent, break, #doc_group{group = X}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent, flat, X} | {tail, HasBreaks, T}]);
%   ## Catch all

fits(Width, _, _, [{Indent, _, #doc_line{}} | T]) ->
    fits(Width, Indent, false, T);
fits(Width, Column, HasBreaks, [{_, _, doc_nil} | T]) ->
    fits(Width, Column, HasBreaks, T);
fits(Width, Column, HasBreaks, [{_, _, #doc_string{length = L}} | T]) ->
    fits(Width, Column + L, HasBreaks, T);
fits(Width, Column, HasBreaks, [{_, _, S} | T]) when is_binary(S) ->
    fits(Width, Column + byte_size(S), HasBreaks, T);
fits(_, _, _, [{_, _, doc_force_breaks} | _]) ->
    false;
fits(Width, Column, _, [{_, _, #doc_break{break = S}} | T]) ->
    fits(Width, Column + byte_size(S), true, T);
fits(Width, Column, HasBreaks, [{Indent, M, #doc_nest{doc = X, always_or_break = break}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent, M, X} | T]);
fits(Width, Column, HasBreaks, [{Indent, M, #doc_nest{doc = X, indent = J}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent + J, M, X} | T]);
fits(Width, Column, HasBreaks, [{Indent, M, #doc_cons{left = X, right = Y}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent, M, X}, {Indent, M, Y} | T]);
fits(Width, Column, HasBreaks, [{Indent, M, #doc_group{group = X}} | T]) ->
    fits(Width, Column, HasBreaks, [{Indent, M, X} | {tail, HasBreaks, T}]).

-spec format(integer() | infinity, integer(), [{integer(), mode(), doc()}]) -> [binary()].
format(_, _, []) ->
    [];
format(Width, Column, [{_, _, doc_nil} | T]) ->
    format(Width, Column, T);
format(Width, _, [{Indent, _, #doc_line{count = Count}} | T]) ->
    NewLines = binary:copy(<<"\n">>, Count - 1),
    [NewLines, indent(Indent) | format(Width, Indent, T)];
format(Width, Column, [{Indent, M, #doc_cons{left = X, right = Y}} | T]) ->
    format(Width, Column, [{Indent, M, X}, {Indent, M, Y} | T]);
format(Width, Column, [{_, _, #doc_string{string = S, length = L}} | T]) ->
    [S | format(Width, Column + L, T)];
format(Width, Column, [{_, _, S} | T]) when is_binary(S) ->
    [S | format(Width, Column + byte_size(S), T)];
format(Width, Column, [{_Indent, _M, doc_force_breaks} | T]) ->
    format(Width, Column, T);
format(Width, Column, [{Indent, M, #doc_fits{group = X}} | T]) ->
    format(Width, Column, [{Indent, M, X} | T]);
%   # Flex breaks are not conditional to the mode
format(Width, K0, [{Indent, M, #doc_break{break = S, flex_or_strict = flex}} | T]) ->
    Column = K0 + byte_size(S),
    case Width == infinity orelse M == flat orelse fits(Width, Column, true, T) of
        true -> [S | format(Width, Column, T)];
        false -> [indent(Indent) | format(Width, Indent, T)]
    end;
%   # Strict breaks are conditional to the mode
format(Width, Column, [{Indent, M, #doc_break{break = S, flex_or_strict = strict}} | T]) ->
    case M of
        break -> [indent(Indent) | format(Width, Indent, T)];
        _ -> [S | format(Width, Column + byte_size(S), T)]
    end;
%   # Nesting is conditional to the mode.
format(Width, Column, [{Indent, M, #doc_nest{doc = X, indent = J, always_or_break = Nest}} | T]) ->
    case Nest == always orelse (Nest == break andalso M == break) of
        true -> format(Width, Column, [{Indent + J, M, X} | T]);
        false -> format(Width, Column, [{Indent, M, X} | T])
    end;
%   # Groups must do the fitting decision.
format(Width, Column, [{Indent, _, #doc_group{group = X}} | T0]) ->
    {StringLength, T1} = peek_next_string_length(T0),
    case Width == infinity orelse fits(Width - StringLength, Column, false, [{Indent, flat, X}]) of
        true ->
            format(Width, Column, [{Indent, flat, X} | T1]);
        false ->
            T = force_next_flex_break(T1),
            format(Width, Column, [{Indent, break, X} | T])
    end.

peek_next_string_length([{Indent, M, #doc_cons{left = Left, right = Right}} | T]) ->
    peek_next_string_length([{Indent, M, Left}, {Indent, M, Right} | T]);
peek_next_string_length([{_, _, #doc_string{length = Length}} | _] = Stack) ->
    {Length, Stack};
peek_next_string_length([{_, _, Binary} | _] = Stack) when is_binary(Binary) ->
    {byte_size(Binary), Stack};
peek_next_string_length(Stack) ->
    {0, Stack}.

%% after a group breaks, we force next flex break to also break
force_next_flex_break([{Indent, M, #doc_break{flex_or_strict = flex} = Break} | T]) ->
    [{Indent, M, Break#doc_break{flex_or_strict = strict}} | T];
force_next_flex_break([{_, _, #doc_break{flex_or_strict = strict}} | _] = Stack) ->
    Stack;
force_next_flex_break([{Indent, M, #doc_cons{left = Left, right = Right}} | T]) ->
    force_next_flex_break([{Indent, M, Left}, {Indent, M, Right} | T]);
force_next_flex_break([Other | T]) ->
    [Other | force_next_flex_break(T)];
force_next_flex_break([]) ->
    [].

indent(0) ->
    ?newline;
indent(Indent) when is_integer(Indent) ->
    Spaces = binary:copy(<<" ">>, Indent),
    <<?newline/binary, Spaces/binary>>.
