%%%% lfmt_fezzik_util: pure leaf helpers (classification, predicates, sorting,
%%%% cons-dot, flat rendering, trivia emission, column math).
%%%% Layer: lexer -> cst -> [util] -> render -> fezzik. Calls nothing above it.
-module(lfmt_fezzik_util).
-include("lfmt_fezzik.hrl").
-export([regime/2, split_dot_tail/2, apply_dot_suffix/3, is_when_form/1, head_has_leading_comment/1, any_dist_has_comment/1, must_break/1, is_export_import_with_entries/1, is_always_break_head/1, is_let_head/1, is_flet_head/1, local_fn_n/1, is_lambda_multi_body/1, is_clause_specform_head/2, is_defun_match_head/2, is_receive_head/1, is_try_head/1, is_export_import_head/1, is_export_entry/1, is_non_neg_integer_text/1, sort_export_entries/1, is_rename_entry/1, sort_rename_entries/1, sort_import_entries/2, is_after_section/1, all_clauses/1, trivial_clause/1, has_clause_internal_trivia/1, is_trivial_datum/1, is_arglist/1, is_force_break_defform/1, has_empty_arglist/1, defform_n/2, classify_head/1, specform_table/0, close_section/8, flat_render/1, flat_width/1, sum_widths/2, add_widths/2, emit_leading_trivia/3, emit_head_leading/2, emit_child_leading/3, emit_trailing/2, emit_dangling/2, emit_toplevel_dangling/1, has_internal_trivia/1, has_descendant_trivia/1, has_comment_leading/1, entry_has_comment/1, col_after_text/2]).
%%====================================================================
%% Internal: regime classification (A7·S2b)
%%====================================================================
-type regime() :: canonical | break_preserving.
%% regime/2: decide per container node whether the formatter owns layout
%% (canonical) or preserves the author's break positions (break_preserving).
%%
%% Rules (in priority order):
%% InData=true → break_preserving (inside a quote — data context)
%% tuple / binary → break_preserving (data containers)
%% map → canonical (k/v pair alignment owned by formatter)
%% list/eval with specform or defform head → canonical
%% any other list/eval (plain call, unknown head, non-symbol head) → break_preserving
%%
%% Note: leaves and prefixed nodes do not take a regime; only containers do.
-spec regime(lfmt_fezzik_cst:cst_node(), boolean()) -> regime().
regime(_Node, true) ->
break_preserving;
regime(Node, false) ->
case lfmt_fezzik_cst:type(Node) of
tuple -> break_preserving;
binary -> break_preserving;
map -> canonical;
T when T =:= list; T =:= eval ->
case lfmt_fezzik_cst:dot_token(Node) of
undefined ->
case lfmt_fezzik_cst:children(Node) of
[Head | _] ->
case classify_head(Head) of
{specform, _} -> canonical;
defform -> canonical;
_ -> break_preserving
end;
[] -> break_preserving
end;
_ ->
break_preserving %% dotted lists are never canonical specforms
end;
_ ->
break_preserving
end.
%%====================================================================
%% Internal: cons-dot helpers (A7·S1)
%%====================================================================
%% split_dot_tail/2: for a dotted list, separate body children from the tail.
split_dot_tail(undefined, Children) -> {Children, none};
split_dot_tail(_, []) -> {[], none};
split_dot_tail(DotTok, Children) ->
{lists:droplast(Children), {DotTok, lists:last(Children)}}.
%% apply_dot_suffix/3: append " . tail" IO after the body, returning updated col.
apply_dot_suffix(none, Col, HasTrail) ->
{[], Col, HasTrail};
apply_dot_suffix({DotTok, TailNode}, Col, _HasTrail) ->
DotText = lfmt_fezzik_lexer:text(DotTok),
TailIO = flat_render(TailNode),
TailW = case flat_width(TailNode) of infinity -> 0; W -> W end,
TailCol = Col + 3 + TailW,
TailHasTrail = lfmt_fezzik_cst:trailing(TailNode) =/= [],
{[" ", DotText, " ", TailIO], TailCol, TailHasTrail}.
%% is_when_form: true if Node is a list whose first child is the symbol "when".
-spec is_when_form(lfmt_fezzik_cst:cst_node()) -> boolean().
is_when_form(Node) ->
lfmt_fezzik_cst:type(Node) =:= list
andalso case lfmt_fezzik_cst:children(Node) of
[WHead | _] ->
lfmt_fezzik_cst:type(WHead) =:= symbol
andalso lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(WHead)) =:= "when";
[] -> false
end.
%% head_has_leading_comment: true iff the node's leading contains a comment.
-spec head_has_leading_comment(lfmt_fezzik_cst:cst_node()) -> boolean().
head_has_leading_comment(Node) ->
lists:any(fun({comment, _}) -> true; (_) -> false end,
lfmt_fezzik_cst:leading(Node)).
%% any_dist_has_comment: true if the distinguished args have an unsafe comment.
%% Safe: trailing comment on the LAST distinguished arg (ends head line; body
%% goes below at +2). Unsafe: leading comment on ANY arg, or trailing comment
%% on a NON-LAST arg (would swallow the next distinguished arg on the same line).
-spec any_dist_has_comment([lfmt_fezzik_cst:cst_node()]) -> boolean().
any_dist_has_comment([]) -> false;
any_dist_has_comment([D]) ->
%% Last item: trailing comment is safe; only leading triggers fallback.
head_has_leading_comment(D);
any_dist_has_comment([D | Rest]) ->
head_has_leading_comment(D)
orelse lfmt_fezzik_cst:trailing(D) =/= []
orelse any_dist_has_comment(Rest).
%% must_break: true when flat rendering must be suppressed regardless of width.
%% • defform-headed lists (defun/defmacro with args, defmodule, defrecord, …)
%% • maps: key-value pairs always on separate lines
%% • list headed by let/let*/case/cond
%% Scope note: flet/fletrec/letrec-function and other let-family forms are NOT
%% forced — they retain flat-if-fits. Extend this list when adjudicated.
-spec must_break(lfmt_fezzik_cst:cst_node()) -> boolean().
must_break(Node) ->
case lfmt_fezzik_cst:type(Node) of
map -> true;
list ->
lfmt_fezzik_cst:dot_token(Node) =:= undefined
andalso (is_force_break_defform(Node)
orelse is_always_break_head(Node)
orelse is_lambda_multi_body(Node)
orelse is_export_import_with_entries(Node));
_ -> false
end.
%% is_export_import_with_entries: true for export/import lists with at least one entry.
%% Empty (export) may stay flat; any entry forces a break.
-spec is_export_import_with_entries(lfmt_fezzik_cst:cst_node()) -> boolean().
is_export_import_with_entries(Node) ->
case lfmt_fezzik_cst:children(Node) of
[Head | Rest] when Rest =/= [] ->
is_export_import_head(Head);
_ ->
false
end.
%% is_always_break_head: true for list nodes headed by a form that must always
%% break (let/let*/case/cond/if/progn/receive/try/maybe/match-lambda).
-spec is_always_break_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_always_break_head(Node) ->
case lfmt_fezzik_cst:children(Node) of
[Head | _] ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "let" orelse Text =:= "let*"
orelse Text =:= "case" orelse Text =:= "cond"
orelse Text =:= "if" orelse Text =:= "progn"
orelse Text =:= "receive" orelse Text =:= "try"
orelse Text =:= "maybe" orelse Text =:= "match-lambda";
_ -> false
end;
[] -> false
end.
%% is_let_head: true when the head symbol is let or let*.
-spec is_let_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_let_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "let" orelse Text =:= "let*";
_ -> false
end.
%% is_flet_head: true when the head symbol is flet, flet*, or fletrec.
-spec is_flet_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_flet_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "flet" orelse Text =:= "flet*" orelse Text =:= "fletrec";
_ -> false
end.
%% local_fn_n: N for rendering a single flet/fletrec binding as a defun-like form.
%% Binding children = [name | rest]. N=1 when rest has an arglist as its first
%% element (signature form: name + arglist on head line); N=0 otherwise (match-
%% clause form: name on head line, clauses at +2).
-spec local_fn_n(lfmt_fezzik_cst:cst_node()) -> non_neg_integer().
local_fn_n(Binding) ->
case lfmt_fezzik_cst:children(Binding) of
[_Name, Arg2 | _] ->
case is_arglist(Arg2) of
true -> 1;
false -> 0
end;
_ -> 0
end.
%% is_lambda_multi_body: true for (lambda arglist body1 body2 …) with >1 body form.
%% Children = [lambda-sym, arglist | body…]; body count > 1 forces a break so the
%% implicit progn is always written one-form-per-line (formatting-rules §3.2).
-spec is_lambda_multi_body(lfmt_fezzik_cst:cst_node()) -> boolean().
is_lambda_multi_body(Node) ->
case lfmt_fezzik_cst:children(Node) of
[Head, _Arglist | Body] ->
length(Body) > 1
andalso lfmt_fezzik_cst:type(Head) =:= symbol
andalso lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)) =:= "lambda";
_ -> false
end.
%% is_clause_specform_head: true for specforms whose body children are clauses.
%% try case/catch sections are intentionally deferred to A7·S4.
-spec is_clause_specform_head(lfmt_fezzik_cst:cst_node(), non_neg_integer()) -> boolean().
is_clause_specform_head(Head, N) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "case" orelse (Text =:= "match-lambda" andalso N =:= 0);
_ ->
false
end.
%% is_defun_match_head: true for defun/defmacro routed through dynamic N=1.
-spec is_defun_match_head(lfmt_fezzik_cst:cst_node(), non_neg_integer()) -> boolean().
is_defun_match_head(Head, 1) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "defun" orelse Text =:= "defmacro";
_ ->
false
end;
is_defun_match_head(_Head, _N) ->
false.
-spec is_receive_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_receive_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)) =:= "receive";
_ ->
false
end.
-spec is_try_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_try_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)) =:= "try";
_ ->
false
end.
-spec is_export_import_head(lfmt_fezzik_cst:cst_node()) -> boolean().
is_export_import_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
Text =:= "export" orelse Text =:= "import";
_ ->
false
end.
%% is_export_entry: true for a 2-child list (symbol name, non-negative integer arity).
%% Used to decide whether to sort export entries.
-spec is_export_entry(lfmt_fezzik_cst:cst_node()) -> boolean().
is_export_entry(Node) ->
case lfmt_fezzik_cst:type(Node) of
list ->
case lfmt_fezzik_cst:children(Node) of
[Name, Arity] ->
lfmt_fezzik_cst:type(Name) =:= symbol
andalso lfmt_fezzik_cst:type(Arity) =:= number
andalso is_non_neg_integer_text(
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Arity)));
_ -> false
end;
_ -> false
end.
-spec is_non_neg_integer_text(string()) -> boolean().
is_non_neg_integer_text([]) -> false;
is_non_neg_integer_text(Text) ->
lists:all(fun(C) -> C >= $0 andalso C =< $9 end, Text).
%% sort_export_entries: stable sort by {name, arity} using keysort.
-spec sort_export_entries([lfmt_fezzik_cst:cst_node()]) ->
[lfmt_fezzik_cst:cst_node()].
sort_export_entries(Entries) ->
Tagged = [begin
[Name, Arity] = lfmt_fezzik_cst:children(E),
NameText = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Name)),
ArityInt = list_to_integer(
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Arity))),
{{NameText, ArityInt}, E}
end || E <- Entries],
[E || {_, E} <- lists:keysort(1, Tagged)].
%% is_rename_entry: true for ((name arity) new-name) — a 2-child list whose first
%% child is itself a valid export entry (name arity).
-spec is_rename_entry(lfmt_fezzik_cst:cst_node()) -> boolean().
is_rename_entry(Node) ->
case lfmt_fezzik_cst:type(Node) of
list ->
case lfmt_fezzik_cst:children(Node) of
[OldPair | _] -> is_export_entry(OldPair);
_ -> false
end;
_ -> false
end.
%% sort_rename_entries: stable sort by old {name, arity} from the inner (name arity) pair.
-spec sort_rename_entries([lfmt_fezzik_cst:cst_node()]) -> [lfmt_fezzik_cst:cst_node()].
sort_rename_entries(Entries) ->
Tagged = [begin
[OldPair | _] = lfmt_fezzik_cst:children(E),
[Name, Arity] = lfmt_fezzik_cst:children(OldPair),
NameText = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Name)),
ArityInt = list_to_integer(
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Arity))),
{{NameText, ArityInt}, E}
end || E <- Entries],
[E || {_, E} <- lists:keysort(1, Tagged)].
%% sort_import_entries: sort from/rename clause entries; suppress when any entry
%% carries a leading comment (preserves developer ordering).
-spec sort_import_entries(string(), [lfmt_fezzik_cst:cst_node()]) ->
[lfmt_fezzik_cst:cst_node()].
sort_import_entries("from", Entries) ->
case lists:all(fun is_export_entry/1, Entries)
andalso not lists:any(fun entry_has_comment/1, Entries) of
true -> sort_export_entries(Entries);
false -> Entries
end;
sort_import_entries("rename", Entries) ->
case lists:all(fun is_rename_entry/1, Entries)
andalso not lists:any(fun entry_has_comment/1, Entries) of
true -> sort_rename_entries(Entries);
false -> Entries
end;
sort_import_entries(_, Entries) ->
Entries.
-spec is_after_section(lfmt_fezzik_cst:cst_node()) -> boolean().
is_after_section(Node) ->
lfmt_fezzik_cst:type(Node) =:= list
andalso case lfmt_fezzik_cst:children(Node) of
[Head | _] ->
lfmt_fezzik_cst:type(Head) =:= symbol
andalso lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)) =:= "after";
[] ->
false
end.
-spec all_clauses([lfmt_fezzik_cst:cst_node()]) -> boolean().
all_clauses(Children) ->
lists:all(
fun(Child) ->
lfmt_fezzik_cst:type(Child) =:= list
andalso lfmt_fezzik_cst:open(Child) =/= undefined
andalso lfmt_fezzik_cst:close(Child) =/= undefined
end, Children).
%%====================================================================
%% Internal: clause helpers (A7·S3b-1)
%%====================================================================
%% trivial_clause: a clause is trivial iff it has exactly two children
%% (pattern + a trivial datum) and carries no internal trivia. Trivial
%% clauses render flat; non-trivial clauses always break (pattern line +
%% body below via the list_head path). The clause's own trailing trivia
%% is handled by the parent loop and does not affect triviality.
-spec trivial_clause(lfmt_fezzik_cst:cst_node()) -> boolean().
trivial_clause(Node) ->
lfmt_fezzik_cst:type(Node) =:= list
andalso not has_clause_internal_trivia(Node)
andalso case lfmt_fezzik_cst:children(Node) of
[_Pattern, Datum] -> is_trivial_datum(Datum);
_ -> false
end.
%% has_clause_internal_trivia: true when the clause itself has a leading comment
%% or dangling trivia, or any descendant has any trivia.
%% The clause's own trailing is excluded (handled externally).
-spec has_clause_internal_trivia(lfmt_fezzik_cst:cst_node()) -> boolean().
has_clause_internal_trivia(Node) ->
has_comment_leading(lfmt_fezzik_cst:leading(Node))
orelse lfmt_fezzik_cst:dangling(Node) =/= []
orelse lists:any(fun has_descendant_trivia/1, lfmt_fezzik_cst:children(Node)).
%% is_trivial_datum: true for a leaf node (symbol/number/string/char) or a
%% prefixed node whose inner is such a leaf.
-spec is_trivial_datum(lfmt_fezzik_cst:cst_node()) -> boolean().
is_trivial_datum(Node) ->
case lfmt_fezzik_cst:type(Node) of
T when T =:= symbol; T =:= number; T =:= string; T =:= char -> true;
prefixed ->
case lfmt_fezzik_cst:children(Node) of
[Inner] ->
case lfmt_fezzik_cst:type(Inner) of
T when T =:= symbol; T =:= number;
T =:= string; T =:= char -> true;
_ -> false
end;
_ -> false
end;
_ -> false
end.
%%====================================================================
%% Internal: defform helpers (A4·S2)
%%====================================================================
%% is_arglist: true for () and (x y z) but NOT for ((pat) body) match clauses.
%% A list whose first child is itself a list is a match clause, not an arglist.
-spec is_arglist(lfmt_fezzik_cst:cst_node()) -> boolean().
is_arglist(Node) ->
lfmt_fezzik_cst:type(Node) =:= list
andalso case lfmt_fezzik_cst:children(Node) of
[] -> true;
[First | _] -> lfmt_fezzik_cst:type(First) =/= list
end.
%% is_force_break_defform: true for defform-headed lists that must always break.
%% Only defun/defmacro with an empty arglist (the constant idiom) are excluded
%% and allowed to be flat-if-fits.
-spec is_force_break_defform(lfmt_fezzik_cst:cst_node()) -> boolean().
is_force_break_defform(Node) ->
case lfmt_fezzik_cst:children(Node) of
[Head | RestChildren] ->
case classify_head(Head) of
defform ->
HeadText = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
IsDefunMacro = HeadText =:= "defun" orelse HeadText =:= "defmacro",
case IsDefunMacro of
true -> not has_empty_arglist(RestChildren);
false -> true %% defmodule, defrecord, etc. always break
end;
_ -> false
end;
_ -> false
end.
%% has_empty_arglist: true when RestChildren is [Name, Arg2 | _] and Arg2 is
%% an arglist with no children (the empty-arglist / constant idiom).
-spec has_empty_arglist([lfmt_fezzik_cst:cst_node()]) -> boolean().
has_empty_arglist([_Name, Arg2 | _]) ->
is_arglist(Arg2) andalso lfmt_fezzik_cst:children(Arg2) =:= [];
has_empty_arglist(_) ->
false.
%% defform_n: compute the number of distinguished args for a breaking defform.
%% defun/defmacro + non-empty arglist as Arg2 → N=2 (signature form)
%% defun/defmacro + match-clause Arg2 (or missing Arg2) → N=1
%% any other defform → N=1 (name on head line, rest at C+2)
-spec defform_n(lfmt_fezzik_cst:cst_node(), [lfmt_fezzik_cst:cst_node()]) ->
pos_integer().
defform_n(Head, RestChildren) ->
HeadText = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
case HeadText =:= "defun" orelse HeadText =:= "defmacro" of
true ->
case RestChildren of
[_Name, Arg2 | _] ->
case is_arglist(Arg2) of
true -> 2; %% (defun name (args) body…)
false -> 1 %% (defun name ((pat) body)…) match clauses
end;
_ -> 1
end;
false ->
1 %% defmodule, defrecord, defstruct, …
end.
%%====================================================================
%% Internal: head classification (A4)
%%====================================================================
%% classify_head: determines indentation class for a breaking list's head.
%% Algorithm (order matters — table wins over def-prefix):
%% 1. Head not a symbol → list_head
%% 2. Head in specform table → {specform, N}
%% 3. Head starts with "def" and length > 3 → defform
%% 4. else → funcall
-spec classify_head(lfmt_fezzik_cst:cst_node()) -> head_class().
classify_head(Head) ->
case lfmt_fezzik_cst:type(Head) of
symbol ->
Text = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Head)),
case maps:find(Text, specform_table()) of
{ok, N} -> {specform, N};
error ->
case length(Text) > 3 andalso lists:prefix("def", Text) of
true -> defform;
false -> funcall
end
end;
_ ->
list_head
end.
%% specform_table: verbatim from lfe-indent.el; maps symbol text → N distinguished args.
%% Intentional extensions beyond lfe-indent.el (per Duncan's ruling):
%% "export" => 0, "import" => 0 — keyword-alone style, items at C+OpenLen (+1), always-break.
%% Other module-clause forms (behaviour, doc, …) could be added similarly.
%% Dialyzer infers a narrower key type ([1..255,...]) than string(); suppress.
-dialyzer({no_underspecs, specform_table/0}).
-spec specform_table() -> #{string() => non_neg_integer()}.
specform_table() ->
#{
":" => 2,
"after" => 1,
"bc" => 1,
"binary-comp" => 1,
"call" => 2,
"case" => 1,
"catch" => 0,
"define-function" => 1,
"define-macro" => 1,
"define-module" => 1,
"export" => 0,
"extend-module" => 0,
"import" => 0,
"do" => 2,
"else" => 0,
"eval-when-compile" => 0,
"flet" => 1,
"flet*" => 1,
"fletrec" => 1,
"if" => 1,
"lambda" => 1,
"let" => 1,
"let*" => 1,
"let-function" => 1,
"letrec-function" => 1,
"let-macro" => 1,
"lc" => 1,
"list-comp" => 1,
"macrolet" => 1,
"match-lambda" => 0,
"match-spec" => 0,
"maybe" => 0,
"prog1" => 1,
"prog2" => 2,
"progn" => 0,
"receive" => 0,
"try" => 0,
"when" => 0,
"syntaxlet" => 1,
"defflavor" => 3,
"begin" => 0,
"let-syntax" => 1,
"syntax-rules" => 0,
"macro" => 0
}.
%% close_section: emit dangling then close, or close hugging last child.
%% Breaks close onto its own line at Indent (content indent) when:
%% • Dangling is non-empty (existing rule), OR
%% • LastHasTrail=true (last child had a trailing comment — fix1: a comment
%% runs to end-of-line so the close must not follow it on the same line).
%% The close aligns with the preceding content/dangling lines (IndStr), never
%% de-indented to the form's open column C (A7·S4b).
-spec close_section([lfmt_fezzik_cst:trivia()], boolean(), non_neg_integer(),
non_neg_integer(), string(), non_neg_integer(), string(), string()) ->
{iolist(), non_neg_integer()}.
close_section([], false, LastCol, _Indent, _IndStr, _C, _CIndStr, Close) ->
{Close, LastCol + length(Close)};
close_section(Dangling, _HasTrail, _LastCol, Indent, IndStr, _C, _CIndStr, Close) ->
DangIO = emit_dangling(Dangling, IndStr),
{[DangIO, "\n", IndStr, Close], Indent + length(Close)}.
%%====================================================================
%% Internal: flat rendering (used when node passes flat check)
%%====================================================================
-spec flat_render(lfmt_fezzik_cst:cst_node()) -> iolist().
flat_render(Node) ->
case lfmt_fezzik_cst:type(Node) of
T when T =:= symbol; T =:= number; T =:= string; T =:= char ->
lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Node));
T when T =:= list; T =:= tuple; T =:= map; T =:= binary; T =:= eval ->
Open = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Node)),
Close = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:close(Node)),
case lfmt_fezzik_cst:children(Node) of
[] -> [Open, Close];
Children ->
case lfmt_fezzik_cst:dot_token(Node) of
undefined ->
[Open, lists:join(" ", [flat_render(C) || C <- Children]), Close];
DotTok ->
AllButLast = lists:droplast(Children),
Tail = lists:last(Children),
DotText = lfmt_fezzik_lexer:text(DotTok),
PreRendered = lists:join(" ", [flat_render(C) || C <- AllButLast]),
[Open, PreRendered, " ", DotText, " ", flat_render(Tail), Close]
end
end;
prefixed ->
PfxText = lfmt_fezzik_lexer:text(lfmt_fezzik_cst:prefix(Node)),
[Inner] = lfmt_fezzik_cst:children(Node),
[PfxText | flat_render(Inner)]
end.
%%====================================================================
%% Internal: flat-width calculation
%%====================================================================
-spec flat_width(lfmt_fezzik_cst:cst_node()) -> width().
flat_width(Node) ->
case lfmt_fezzik_cst:type(Node) of
T when T =:= symbol; T =:= number; T =:= string; T =:= char ->
Tok = lfmt_fezzik_cst:open(Node),
case lfmt_fezzik_lexer:kind(Tok) of
K when K =:= tqstring; K =:= tqbstring -> infinity;
_ ->
length(lfmt_fezzik_lexer:text(Tok))
end;
T when T =:= list; T =:= tuple; T =:= map; T =:= binary; T =:= eval ->
%% must_break: defforms, maps, and let/let*/case/cond lists always break.
case must_break(Node) of
true -> infinity;
false ->
OpenLen = length(lfmt_fezzik_lexer:text(lfmt_fezzik_cst:open(Node))),
CloseLen = length(lfmt_fezzik_lexer:text(lfmt_fezzik_cst:close(Node))),
Children = lfmt_fezzik_cst:children(Node),
DotTok = lfmt_fezzik_cst:dot_token(Node),
case Children of
[] -> OpenLen + CloseLen;
_ ->
Widths = [flat_width(C) || C <- Children],
%% Dotted: " . " before tail adds 2 extra chars vs plain " ".
Spaces = case DotTok of
undefined -> length(Children) - 1;
_ -> length(Children) + 1
end,
add_widths(OpenLen + CloseLen + Spaces, sum_widths(Widths, 0))
end
end;
prefixed ->
PfxLen = length(lfmt_fezzik_lexer:text(lfmt_fezzik_cst:prefix(Node))),
[Inner] = lfmt_fezzik_cst:children(Node),
add_widths(PfxLen, flat_width(Inner))
end.
-spec sum_widths([width()], non_neg_integer()) -> width().
sum_widths([], Acc) -> Acc;
sum_widths([infinity | _], _) -> infinity;
sum_widths([W | Rest], Acc) -> sum_widths(Rest, Acc + W).
-spec add_widths(non_neg_integer(), width()) -> width().
add_widths(_, infinity) -> infinity;
add_widths(A, B) -> A + B.
%%====================================================================
%% Internal: trivia emission helpers
%%====================================================================
%% emit_leading_trivia: emit leading trivia items at IndentStr.
%% DropFirstBlank=true suppresses the first blank (doc-start / after opener).
-spec emit_leading_trivia([lfmt_fezzik_cst:trivia()], string(), boolean()) -> iolist().
emit_leading_trivia([], _IndStr, _DropFirstBlank) ->
[];
emit_leading_trivia([blank | Rest], IndStr, true) ->
emit_leading_trivia(Rest, IndStr, false);
emit_leading_trivia([blank | Rest], IndStr, false) ->
["\n" | emit_leading_trivia(Rest, IndStr, false)];
emit_leading_trivia([{comment, Tok} | Rest], IndStr, _Drop) ->
Text = lfmt_fezzik_lexer:text(Tok),
[IndStr, Text, "\n" | emit_leading_trivia(Rest, IndStr, false)].
%% emit_head_leading: leading trivia for the head child, emitted before the opener.
%% Blanks are always dropped (head is on the opener line; no blank between leading
%% comments and the opener itself is unusual enough to discard in generic mode).
-spec emit_head_leading([lfmt_fezzik_cst:trivia()], string()) -> iolist().
emit_head_leading([], _CIndStr) ->
[];
emit_head_leading([blank | Rest], CIndStr) ->
emit_head_leading(Rest, CIndStr);
emit_head_leading([{comment, Tok} | Rest], CIndStr) ->
Text = lfmt_fezzik_lexer:text(Tok),
[CIndStr, Text, "\n" | emit_head_leading(Rest, CIndStr)].
%% emit_child_leading: leading trivia for a rest child at IndentStr.
%% IsFirst=true drops the first blank (no blank immediately after head line).
-spec emit_child_leading([lfmt_fezzik_cst:trivia()], string(), boolean()) -> iolist().
emit_child_leading(Leading, IndentStr, IsFirst) ->
emit_leading_trivia(Leading, IndentStr, IsFirst).
%% emit_trailing: emit a trailing comment (if any) on the same line as the node.
-spec emit_trailing([lfmt_fezzik_cst:trivia()], non_neg_integer()) ->
{iolist(), non_neg_integer()}.
emit_trailing([], Col) ->
{[], Col};
emit_trailing([{comment, Tok}], Col) ->
Text = lfmt_fezzik_lexer:text(Tok),
NewCol = col_after_text(Text, Col + 1),
{[" ", Text], NewCol}.
%% emit_dangling: emit dangling trivia items, each on its own line at IndentStr.
%% The leading \n for each item is included (caller appends \nCIndStr+close after).
-spec emit_dangling([lfmt_fezzik_cst:trivia()], string()) -> iolist().
emit_dangling([], _IndStr) ->
[];
emit_dangling([{comment, Tok} | Rest], IndStr) ->
Text = lfmt_fezzik_lexer:text(Tok),
["\n", IndStr, Text | emit_dangling(Rest, IndStr)];
emit_dangling([blank | Rest], IndStr) ->
["\n" | emit_dangling(Rest, IndStr)].
%% emit_toplevel_dangling: trailing trivia after the last top-level form.
%% Blanks are dropped; comments are emitted at column 0.
-spec emit_toplevel_dangling([lfmt_fezzik_cst:trivia()]) -> iolist().
emit_toplevel_dangling([]) ->
[];
emit_toplevel_dangling([blank | Rest]) ->
emit_toplevel_dangling(Rest);
emit_toplevel_dangling([{comment, Tok} | Rest]) ->
Text = lfmt_fezzik_lexer:text(Tok),
[Text, "\n" | emit_toplevel_dangling(Rest)].
%%====================================================================
%% Internal: flat-eligibility helpers
%%====================================================================
%% has_internal_trivia: true if flat-rendering this node would silently drop trivia.
%% A node's own leading/trailing are emitted by the parent context (not by
%% flat_render), so they do not prevent flat mode. Only:
%% • the node's own dangling (inside the container content)
%% • any leading/trailing/dangling on any descendant
%% force breaking.
-spec has_internal_trivia(lfmt_fezzik_cst:cst_node()) -> boolean().
has_internal_trivia(Node) ->
lfmt_fezzik_cst:dangling(Node) =/= []
orelse lists:any(fun has_descendant_trivia/1, lfmt_fezzik_cst:children(Node)).
-spec has_descendant_trivia(lfmt_fezzik_cst:cst_node()) -> boolean().
has_descendant_trivia(Node) ->
has_comment_leading(lfmt_fezzik_cst:leading(Node))
orelse lfmt_fezzik_cst:trailing(Node) =/= []
orelse lfmt_fezzik_cst:dangling(Node) =/= []
orelse lists:any(fun has_descendant_trivia/1, lfmt_fezzik_cst:children(Node)).
%% Blank-only leading does not prevent flat rendering: blanks are always dropped
%% or collapsed in broken mode, so they never carry observable information.
%% Only a leading comment forces broken layout (otherwise it would be silently lost).
-spec has_comment_leading([lfmt_fezzik_cst:trivia()]) -> boolean().
has_comment_leading([]) -> false;
has_comment_leading([blank | Rest]) -> has_comment_leading(Rest);
has_comment_leading([{comment,_}|_]) -> true.
%% entry_has_comment: true when an entry node carries a leading OR trailing comment.
%% Used for sort suppression — any developer comment signals intentional ordering.
-spec entry_has_comment(lfmt_fezzik_cst:cst_node()) -> boolean().
entry_has_comment(E) ->
has_comment_leading(lfmt_fezzik_cst:leading(E))
orelse lfmt_fezzik_cst:trailing(E) =/= [].
%%====================================================================
%% Internal: column helpers
%%====================================================================
-spec col_after_text(string(), non_neg_integer()) -> non_neg_integer().
col_after_text([], Col) -> Col;
col_after_text([$\n | Rest], _) -> col_after_text(Rest, 0);
col_after_text([_ | Rest], Col) -> col_after_text(Rest, Col + 1).