src/recon_rec.erl

%%%-------------------------------------------------------------------
%%% @author bartlomiej.gorny@erlang-solutions.com
%%% @doc
%%% This module handles formatting records for known record types.
%%% Record definitions are imported from modules by user. Definitions are
%%% distinguished by record name and its arity, if you have multiple records
%%% of the same name and size, you have to choose one of them and some of your
%%% records may be wrongly labelled. You can manipulate your definition list by
%%% using import/1 and clear/1, and check which definitions are in use by executing
%%% list/0.
%%% @end
%%%-------------------------------------------------------------------
-module(recon_rec).
-author("bartlomiej.gorny@erlang-solutions.com").
%% API

-export([is_active/0]).
-export([import/1, clear/1, clear/0, list/0, get_list/0, limit/3]).
-export([format_tuple/1]).

-ifdef(TEST).
-export([lookup_record/2]).
-endif.

% basic types
-type field() :: atom().
-type record_name() :: atom().
% compound
-type limit() :: all | none | field() | [field()].
-type listentry() :: {module(), record_name(), [field()], limit()}.
-type import_result() :: {imported, module(), record_name(), arity()}
                       | {overwritten, module(), record_name(), arity()}
                       | {ignored, module(), record_name(), arity(), module()}.

%% @doc import record definitions from a module. If a record definition of the same name
%% and arity has already been imported from another module then the new
%% definition is ignored (returned info tells you from which module the existing definition was imported).
%% You have to choose one and possibly remove the old one using
%% clear/1. Supports importing multiple modules at once (by giving a list of atoms as
%% an argument).
%% @end
-spec import(module() | [module()]) -> import_result() | [import_result()].
import(Modules) when is_list(Modules) ->
    lists:foldl(fun import/2, [], Modules);
import(Module) ->
    import(Module, []).

%% @doc quickly check if we want to do any record formatting
-spec is_active() -> boolean().
is_active() ->
    case whereis(recon_ets) of
        undefined -> false;
        _ -> true
    end.

%% @doc remove definitions imported from a module.
clear(Module) ->
    lists:map(fun(R) -> rem_for_module(R, Module) end, ets:tab2list(records_table_name())).

%% @doc remove all imported definitions, destroy the table, clean up
clear() ->
    maybe_kill(recon_ets),
    ok.

%% @doc prints out all "known" (imported) record definitions and their limit settings.
%% Printout tells module a record originates from, its name and a list of field names,
%% plus the record's arity (may be handy if handling big records) and a list of field it
%% limits its output to, if set.
%% @end
list() ->
    F = fun({Module, Name, Fields, Limits}) ->
            Fnames = lists:map(fun atom_to_list/1, Fields),
            Flds = join(",", Fnames),
            io:format("~p: #~p(~p){~s} ~p~n",
                      [Module, Name, length(Fields), Flds, Limits])
        end,
    io:format("Module: #Name(Size){<Fields>} Limits~n==========~n", []),
    lists:foreach(F, get_list()).

%% @doc returns a list of active record definitions
-spec get_list() -> [listentry()].
get_list() ->
    ensure_table_exists(),
    Lst = lists:map(fun make_list_entry/1, ets:tab2list(records_table_name())),
    lists:sort(Lst).

%% @doc Limit output to selected fields of a record (can be 'none', 'all', a field or a list of fields).
%% Limit set to 'none' means there is no limit, and all fields are displayed; limit 'all' means that
%% all fields are squashed and only record name will be shown.
%% @end
-spec limit(record_name(), arity(), limit()) -> ok | {error, any()}.
limit(Name, Arity, Limit) when is_atom(Name), is_integer(Arity) ->
    case lookup_record(Name, Arity) of
        [] ->
            {error, record_unknown};
        [{Key, Fields, Mod, _}] ->
            ets:insert(records_table_name(), {Key, Fields, Mod, Limit}),
            ok
    end.

%% @private if a tuple is a known record, formats is as "#recname{field=value}", otherwise returns
%% just a printout of a tuple.
format_tuple(Tuple) ->
    ensure_table_exists(),
    First = element(1, Tuple),
    format_tuple(First, Tuple).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% PRIVATE
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%


make_list_entry({{Name, _}, Fields, Module, Limits}) ->
    FmtLimit = case Limits of
                   [] -> none;
                   Other -> Other
               end,
    {Module, Name, Fields, FmtLimit}.

import(Module, ResultList) ->
    ensure_table_exists(),
    lists:foldl(fun(Rec, Res) -> store_record(Rec, Module, Res) end,
                ResultList,
                get_record_defs(Module)).

store_record(Rec, Module, ResultList) ->
    {Name, Fields} = Rec,
    Arity = length(Fields),
    Result = case lookup_record(Name, Arity) of
        [] ->
            ets:insert(records_table_name(), rec_info(Rec, Module)),
            {imported, Module, Name, Arity};
        [{_, _, Module, _}] ->
            ets:insert(records_table_name(), rec_info(Rec, Module)),
            {overwritten, Module, Name, Arity};
        [{_, _, Mod, _}] ->
            {ignored, Module, Name, Arity, Mod}
    end,
    [Result | ResultList].

get_record_defs(Module) ->
    Path = code:which(Module),
    {ok,{_,[{abstract_code,{_,AC}}]}} = beam_lib:chunks(Path, [abstract_code]),
    lists:foldl(fun get_record/2, [], AC).

get_record({attribute, _, record, Rec}, Acc) -> [Rec | Acc];
get_record(_, Acc) -> Acc.

%% @private
lookup_record(RecName, FieldCount) ->
    ensure_table_exists(),
    ets:lookup(records_table_name(), {RecName, FieldCount}).

%% @private
ensure_table_exists() ->
    case ets:info(records_table_name()) of
        undefined ->
            case whereis(recon_ets) of
                undefined ->
                    Parent = self(),
                    Ref = make_ref(),
                    %% attach to the currently running session
                    {Pid, MonRef} = spawn_monitor(fun() ->
                        register(recon_ets, self()),
                        ets:new(records_table_name(), [set, public, named_table]),
                        Parent ! Ref,
                        ets_keeper()
                    end),
                    receive
                        Ref ->
                            erlang:demonitor(MonRef, [flush]),
                            Pid;
                        {'DOWN', MonRef, _, _, Reason} ->
                            error(Reason)
                    end;
                Pid ->
                    Pid
            end;
        Pid ->
            Pid
    end.

records_table_name() -> recon_record_definitions.

rec_info({Name, Fields}, Module) ->
    {{Name, length(Fields)}, field_names(Fields), Module, none}.

rem_for_module({_, _, Module, _} = Rec, Module) ->
    ets:delete_object(records_table_name(), Rec);
rem_for_module(_, _) ->
    ok.

ets_keeper() ->
    receive
        stop -> ok;
        _ -> ets_keeper()
    end.

field_names(Fields) ->
    lists:map(fun field_name/1, Fields).

field_name({record_field, _, {atom, _, Name}}) -> Name;
field_name({record_field, _, {atom, _, Name}, _Default}) -> Name;
field_name({typed_record_field, Field, _Type}) -> field_name(Field).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% FORMATTER
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

format_tuple(Name, Rec) when is_atom(Name) ->
    case lookup_record(Name, size(Rec) - 1) of
        [RecDef] -> format_record(Rec, RecDef);
        _ ->
            List = tuple_to_list(Rec),
            ["{", join(", ", [recon_trace:format_trace_output(true, El) || El <- List]), "}"]
    end;
format_tuple(_, Tuple) ->
    format_default(Tuple).

format_default(Val) ->
    io_lib:format("~p", [Val]).

format_record(Rec, {{Name, Arity}, Fields, _, Limits}) ->
    ExpectedLength = Arity + 1,
    case tuple_size(Rec) of
        ExpectedLength ->
            [_ | Values] = tuple_to_list(Rec),
            List = lists:zip(Fields, Values),
            LimitedList = apply_limits(List, Limits),
            ["#", atom_to_list(Name), "{",
             join(", ", [format_kv(Key, Val) || {Key, Val} <- LimitedList]),
             "}"];
        _ ->
            format_default(Rec)
    end.

format_kv(Key, Val) ->
    %% Some messy mutually recursive calls we can't avoid
    [recon_trace:format_trace_output(true, Key), "=", recon_trace:format_trace_output(true, Val)].

apply_limits(List, none) -> List;
apply_limits(_List, all) -> [];
apply_limits(List, Field) when is_atom(Field) ->
    [{Field, proplists:get_value(Field, List)}, {more, '...'}];
apply_limits(List, Limits) ->
    lists:filter(fun({K, _}) -> lists:member(K, Limits) end, List) ++ [{more, '...'}].

%%%%%%%%%%%%%%%
%%% HELPERS %%%
%%%%%%%%%%%%%%%

maybe_kill(Name) ->
    case whereis(Name) of
        undefined ->
            ok;
        Pid ->
            unlink(Pid),
            exit(Pid, kill),
            wait_for_death(Pid, Name)
    end.

wait_for_death(Pid, Name) ->
    case is_process_alive(Pid) orelse whereis(Name) =:= Pid of
        true ->
            timer:sleep(10),
            wait_for_death(Pid, Name);
        false ->
            ok
    end.

-ifdef(OTP_RELEASE).
-spec join(term(), [term()]) -> [term()].
join(Sep, List) ->
    lists:join(Sep, List).
-else.
-spec join(string(), [string()]) -> string().
join(Sep, List) ->
    string:join(List, Sep).
-endif.