Skip to main content

src/lfmt_fezzik_util.erl

%%%% 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).