Current section

Files

Jump to
erlfmt src erlfmt_algebra.erl
Raw

src/erlfmt_algebra.erl

%% Copyright (c) Facebook, Inc. and its 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.
-module(erlfmt_algebra).
-export([string_new/1, string_append/2, string_spaces/1, string_text/1, string_length/1]).
-export([lines_new/1, lines_combine/2, lines_flush/1, lines_render/1]).
-export([
metric_new/1,
metric_combine/2,
metric_flush/1,
metric_render/1,
metric_dominates/2
]).
-export([
document_text/1,
document_spaces/1,
document_combine/2,
document_flush/1,
document_choice/2,
document_single_line/1,
document_prepend/2,
document_render/2,
document_reduce/2,
document_fail/0
]).
-export_type([text/0, str/0, lines/0, metric/0, document/0, option/0]).
-type text() :: unicode:chardata().
-record(string, {length :: non_neg_integer(), text :: text()}).
-opaque str() :: #string{}.
-record(lines_combine, {left :: lines(), right :: lines()}).
-record(lines_flush, {lines :: lines()}).
-opaque lines() :: #string{} | #lines_combine{} | #lines_flush{}.
%% TODO: could we use a more efficient data structure here?
%% We'd benefit from efficient operations at both ends, could a queue be the answer?
-record(doc_seq, {seq :: nonempty_list(document())}).
-record(doc_flush, {doc :: document()}).
-record(doc_choice, {choices :: nonempty_list(document())}).
-record(doc_fail, {}).
-opaque document() :: #string{} | #doc_seq{} | #doc_flush{} | #doc_choice{} | #doc_fail{}.
%% Order of fields is important for comparisons!
-record(metric, {
height :: non_neg_integer(),
max_width :: non_neg_integer(),
last_width :: non_neg_integer()
}).
-opaque metric() :: #metric{}.
-type option() :: {page_width, pos_integer()}.
-define(DEFAULT_PAGE_WIDTH, 80).
-spec string_new(text()) -> str().
string_new(Text) -> #string{length = string:length(Text), text = Text}.
-spec string_spaces(integer()) -> str().
string_spaces(Count) -> #string{length = Count, text = binary:copy(<<" ">>, Count)}.
-spec string_append(str(), str()) -> str().
string_append(Left, Right) ->
#string{
length = Left#string.length + Right#string.length,
text = [Left#string.text | Right#string.text]
}.
-spec string_text(str()) -> text().
string_text(#string{text = Text}) -> Text.
-spec string_length(str()) -> non_neg_integer().
string_length(#string{length = Length}) -> Length.
string_empty() -> #string{length = 0, text = ""}.
-spec lines_new(str()) -> lines().
lines_new(#string{} = String) -> String.
-spec lines_combine(lines(), lines()) -> lines().
lines_combine(#string{} = Left, #string{} = Right) ->
string_append(Left, Right);
lines_combine(Left, Right) ->
#lines_combine{left = Left, right = Right}.
-spec lines_flush(lines()) -> lines().
lines_flush(Lines) ->
#lines_flush{lines = Lines}.
-spec lines_render(lines()) -> text().
lines_render(Lines) ->
tl(strings_to_text(do_lines_render(Lines))).
strings_to_text([#string{text = Text} | Rest]) ->
case string:trim(Text, leading, " ") of
[] -> [$\n | strings_to_text(Rest)];
_ -> [$\n, string:trim(Text, trailing, " ") | strings_to_text(Rest)]
end;
strings_to_text([]) ->
[].
%% TODO: there's probably a more efficient way to do this, possibly
%% by using a better data structure or making it tail-recursive
-spec do_lines_render(lines()) -> [str()].
do_lines_render(#string{} = Line) ->
[Line];
do_lines_render(#lines_combine{left = Left, right = Right}) ->
{LeftLast, LeftHeadRev} = split_last(do_lines_render(Left)),
{RightFirst, RightTail} = split_first(do_lines_render(Right)),
CombinedLine = string_append(LeftLast, RightFirst),
IndentedLines = indent_lines(LeftLast#string.length, RightTail),
lists:reverse(LeftHeadRev, [CombinedLine | IndentedLines]);
do_lines_render(#lines_flush{lines = Lines}) ->
do_lines_render(Lines) ++ [string_empty()].
split_first([First | Rest]) -> {First, Rest}.
split_last([Line | Lines]) -> split_last(Lines, Line, []).
split_last([Line | Rest], Last, LinesRev) -> split_last(Rest, Line, [Last | LinesRev]);
split_last([], Last, LinesRev) -> {Last, LinesRev}.
indent_lines(0, Lines) ->
Lines;
indent_lines(N, Lines) ->
Offset = string_spaces(N),
[string_append(Offset, Line) || Line <- Lines].
-spec metric_new(str()) -> metric().
metric_new(#string{length = Length}) ->
#metric{height = 0, max_width = Length, last_width = Length}.
-spec metric_combine(metric(), metric()) -> metric().
metric_combine(Left, Right) ->
#metric{
height = Left#metric.height + Right#metric.height,
max_width =
max(Left#metric.max_width, Right#metric.max_width + Left#metric.last_width),
last_width = Left#metric.last_width + Right#metric.last_width
}.
-spec metric_flush(metric()) -> metric().
metric_flush(Metric) ->
Metric#metric{height = Metric#metric.height + 1, last_width = 0}.
-spec metric_render(metric()) -> text().
metric_render(Metric) ->
Line = lists:duplicate(Metric#metric.max_width, $x),
LastLine = lists:duplicate(Metric#metric.last_width, $x),
[lists:duplicate(Metric#metric.height, [Line, $\n]) | LastLine].
-spec metric_dominates(metric(), metric()) -> boolean().
metric_dominates(Metric0, Metric1) ->
Metric0#metric.height =< Metric1#metric.height andalso
Metric0#metric.last_width =< Metric1#metric.last_width andalso
Metric0#metric.max_width =< Metric1#metric.max_width.
-spec document_text(text()) -> document().
document_text(Text) -> string_new(Text).
-spec document_spaces(integer()) -> document().
document_spaces(Count) -> string_spaces(Count).
-spec document_combine(document(), document()) -> document().
document_combine(#doc_fail{}, _) ->
#doc_fail{};
document_combine(_, #doc_fail{}) ->
#doc_fail{};
document_combine(#string{} = Left, #string{} = Right) ->
string_append(Left, Right);
document_combine(#doc_seq{seq = Seq1}, #doc_seq{seq = Seq2}) ->
#doc_seq{seq = Seq1 ++ Seq2};
document_combine(#doc_seq{seq = Seq}, Document) ->
#doc_seq{seq = Seq ++ [Document]};
document_combine(Document, #doc_seq{seq = Seq}) ->
#doc_seq{seq = [Document | Seq]};
document_combine(Document1, Document2) ->
#doc_seq{seq = [Document1, Document2]}.
-spec document_flush(document()) -> document().
document_flush(Document) -> #doc_flush{doc = Document}.
-spec document_fail() -> document().
document_fail() -> #doc_fail{}.
-spec document_choice(document(), document()) -> document().
%% TODO: try to reduce the number of choices if both alternatives have the same
%% elements by folidng them together - e.g. same prefix/suffix for seq or both flush.
document_choice(#doc_fail{}, Document) ->
Document;
document_choice(Document, #doc_fail{}) ->
Document;
document_choice(#doc_choice{choices = Choices1}, #doc_choice{choices = Choices2}) ->
#doc_choice{choices = Choices1 ++ Choices2};
document_choice(#doc_choice{choices = Choices}, Document) ->
#doc_choice{choices = [Document | Choices]};
document_choice(Document, #doc_choice{choices = Choices}) ->
#doc_choice{choices = [Document | Choices]};
document_choice(Document1, Document2) ->
#doc_choice{choices = [Document1, Document2]}.
-spec document_single_line(document()) -> document().
document_single_line(#doc_flush{}) -> #doc_fail{};
document_single_line(#string{} = String) -> String;
document_single_line(#doc_fail{} = Fail) -> Fail;
document_single_line(#doc_choice{choices = Choices}) -> choice_single_line(Choices, []);
document_single_line(#doc_seq{seq = Seq}) -> seq_single_line(Seq, []).
choice_single_line([Choice | Choices], Acc) ->
case document_single_line(Choice) of
#doc_fail{} -> choice_single_line(Choices, Acc);
Document -> choice_single_line(Choices, [Document | Acc])
end;
choice_single_line([], []) ->
#doc_fail{};
choice_single_line([], [SingleChoice]) ->
SingleChoice;
choice_single_line([], Choices) ->
#doc_choice{choices = Choices}.
seq_single_line([Doc0 | Docs], Acc) ->
case document_single_line(Doc0) of
#doc_fail{} -> #doc_fail{};
Doc -> seq_single_line(Docs, [Doc | Acc])
end;
seq_single_line([], Acc) ->
#doc_seq{seq = lists:reverse(Acc)}.
-spec document_prepend(document(), document()) -> document().
document_prepend(#doc_fail{}, _) ->
#doc_fail{};
document_prepend(_, #doc_fail{}) ->
#doc_fail{};
document_prepend(#string{} = Left, #string{} = Right) ->
string_append(Left, Right);
document_prepend(Document1, #string{} = Document2) ->
#doc_seq{seq = [Document1, Document2]};
document_prepend(Document, #doc_seq{seq = [SeqHead | Seq]}) ->
#doc_seq{seq = [document_prepend(Document, SeqHead) | Seq]};
document_prepend(Document, #doc_flush{doc = Inner}) ->
#doc_flush{doc = document_prepend(Document, Inner)};
document_prepend(Document, #doc_choice{choices = Choices}) ->
#doc_choice{choices = [document_prepend(Document, Choice) || Choice <- Choices]}.
-spec document_render(document(), [option()]) -> text().
document_render(Document, Options) ->
PageWidth = proplists:get_value(page_width, Options, ?DEFAULT_PAGE_WIDTH),
Layouts0 = document_interpret(Document, PageWidth),
case reject_invalid(Layouts0, PageWidth, Options) of
[] ->
error(no_viable_layout);
Layouts ->
[{_Metric, Lines} | _] = lists:keysort(1, Layouts),
lines_render(Lines)
end.
%% Same as lists:foldr/3 except it doesn't need initial accumulator
%% and just uses the last element of the list for that purpose.
-spec document_reduce(Reducer, [document(), ...]) -> document()
when Reducer :: fun((document(), document()) -> document()).
document_reduce(_Fun, [Doc]) -> Doc;
document_reduce(Fun, [Doc | Rest]) -> Fun(Doc, document_reduce(Fun, Rest)).
-spec document_interpret(document(), non_neg_integer()) -> [{metric(), lines()}].
document_interpret(#string{} = String, _PageWidth) ->
[{metric_new(String), lines_new(String)}];
document_interpret(#doc_flush{doc = Document}, PageWidth) ->
Layouts0 = document_interpret(Document, PageWidth),
Layouts = [{metric_flush(Metric), lines_flush(Lines)} || {Metric, Lines} <- Layouts0],
lists:foldl(fun pareto_frontier_add/2, [], Layouts);
document_interpret(#doc_fail{}, _PageWidth) ->
[];
document_interpret(#doc_seq{seq = [Doc | Seq]}, PageWidth) ->
interpret_seq(Seq, document_interpret(Doc, PageWidth), PageWidth);
document_interpret(#doc_choice{choices = [Choice | Choices]}, PageWidth) ->
interpret_choice(Choices, document_interpret(Choice, PageWidth), PageWidth).
interpret_choice([Choice | Choices], Frontier0, PageWidth) ->
Layouts = document_interpret(Choice, PageWidth),
Frontier = lists:foldl(fun pareto_frontier_add/2, Frontier0, Layouts),
interpret_choice(Choices, Frontier, PageWidth);
interpret_choice([], Frontier, _PageWidth) ->
Frontier.
interpret_seq([Doc | Seq], Lefts, PageWidth) ->
case document_interpret(Doc, PageWidth) of
[] ->
[];
Rights ->
CombinedFrontier = layout_combine_many(Lefts, Rights, PageWidth, [], []),
interpret_seq(Seq, CombinedFrontier, PageWidth)
end;
interpret_seq(_Seq, [], _PageWidth) ->
[];
interpret_seq([], Acc, _PageWidth) ->
Acc.
layout_combine_many([Left | Lefts], Rights, PageWidth, Frontier0, Unfit0) ->
{Frontier, Unfit} = layout_combine_many1(Left, Rights, PageWidth, Frontier0, Unfit0),
layout_combine_many(Lefts, Rights, PageWidth, Frontier, Unfit);
layout_combine_many([], _Rights, _PageWidth, [], Unfit) ->
best_unfit(Unfit);
layout_combine_many([], _Rights, _PageWidth, Frontier, _Unfit) ->
Frontier.
layout_combine_many1(
{LMetric, LLines} = Left,
[{RMetric, RLines} | Rights],
PageWidth,
Frontier0,
Unfit0
) ->
Metric = metric_combine(LMetric, RMetric),
Layout = {Metric, lines_combine(LLines, RLines)},
case Metric#metric.max_width =< PageWidth of
true ->
Frontier = pareto_frontier_add(Layout, Frontier0),
%% We can discard unfit, since we know there's at least one fitting layout
layout_combine_many1(Left, Rights, PageWidth, Frontier, []);
false ->
layout_combine_many1(Left, Rights, PageWidth, Frontier0, [Layout | Unfit0])
end;
layout_combine_many1(_Left, [], _PageWidth, Frontier, Unfit) ->
{Frontier, Unfit}.
%% TODO: if we kept some order in how we add new layouts, we could probably
%% avoid this re-filtering step.
pareto_frontier_add({Metric, _} = Layout, Layouts) ->
case any_dominates(Metric, Layouts) of
true -> Layouts;
false -> [Layout | filter_dominated(Metric, Layouts)]
end.
any_dominates(Metric, [Layout | Layouts]) ->
metric_dominates(element(1, Layout), Metric) orelse any_dominates(Metric, Layouts);
any_dominates(_Metric, []) ->
false.
filter_dominated(Metric, Layouts) ->
[Layout || Layout <- Layouts, not metric_dominates(Metric, element(1, Layout))].
reject_invalid(Layouts, MaxWidth, Options) ->
AllowUnfit = proplists:get_value(allow_unfit, Options, true),
case [Layout || {Metric, _} = Layout <- Layouts, Metric#metric.max_width =< MaxWidth] of
[] when AllowUnfit -> best_unfit(Layouts);
[] -> [];
Filtered -> Filtered
end.
best_unfit([]) -> [];
best_unfit([First | Rest]) -> [best_unfit(Rest, First)].
best_unfit([{CandidateMetric, _} = Candidate | Rest], {BestMetric, _})
when CandidateMetric#metric.max_width < BestMetric#metric.max_width ->
best_unfit(Rest, Candidate);
best_unfit([_Candidate | Rest], Best) ->
best_unfit(Rest, Best);
best_unfit([], Best) ->
Best.