Current section

Files

Jump to
assert src wa_blame.erl
Raw

src/wa_blame.erl

%% 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.
%% % @format
-module(wa_blame).
%% erlfmt:ignore
% @fb-only
-compile(warn_missing_spec_all).
-include_lib("kernel/include/logger.hrl").
-export([
blame/2,
pp_blame/3,
error_info/2
]).
-export([format_blame/2, format_error/2]).
-type stacktrace() :: [dynamic()].
-type stacktrace_extrainfo() ::
{error_info, #{module => module(), function => atom(), cause => term()}}
| {atom(), term()}.
-type debug_info() :: dynamic().
-type tree() :: erl_syntax:syntaxTree().
-type bindings() :: orddict:orddict().
-type blame_info() :: {clauses, [clause_info()]} | {generic, [pattern_info()]}.
-type clause_info() :: {[pattern_info()], [conjunction_info()]}.
-type pattern_info() :: {badkey | boolean() | container_info(), tree()}.
-type container_type() :: map | tuple.
-type container_info() :: {container_type(), [container_field_info()]}.
-type container_field_info() :: {pattern_info(), tree()}.
-type guard_info() :: {boolean(), tree()}.
-type conjunction_info() :: [guard_info()].
-type doc() :: erlfmt_algebra:doc().
-type error_info() :: #{cause => term(), module => module(), function => atom()}.
-type error_description() :: #{
pos_integer() => unicode:chardata(), general => unicode:chardata(), reason => unicode:chardata()
}.
-spec blame(term(), stacktrace()) -> {ok, blame_info()} | {error, no_blame_info}.
blame(function_clause, [{Module, Function, Args, _ExtraInfo} | _] = _StackTrace) ->
{ok, blame_mfa(Module, Function, Args)};
blame({assertMatch, Reason}, [{Module, _Function, _Args, ExtraInfo} | _] = _StackTrace) when is_list(Reason) ->
Value = proplists:get_value(value, Reason),
case extract_pattern(ExtraInfo) of
{ok, Pattern0} ->
Bindings = extract_bindings(ExtraInfo),
Pattern = expand_records(Module, Pattern0),
{PatternInfo, _NewBindings} = blame_pattern(Pattern, Value, Bindings),
{ok, {generic, [PatternInfo]}};
{error, no_pattern} ->
{error, no_blame_info}
end;
blame(_Reason, _StackTrace) ->
{error, no_blame_info}.
-spec expand_records(atom(), tree()) -> tree().
expand_records(Module, Expr) ->
CompileOpts = [],
Records = get_records(Module),
Forms = Records ++ [wrap_in_function('dummy_function', Expr)],
Expanded = erl_expand_records:module(Forms, CompileOpts),
Function = lists:last(Expanded),
extract_from_function(Function).
-spec wrap_in_function(atom(), tree()) -> erl_parse:abstract_form().
wrap_in_function(Name, Pattern) ->
Clauses = [erl_syntax:clause([Pattern], none, [erl_syntax:atom('ok')])],
erl_syntax:revert(erl_syntax:function(erl_syntax:atom(Name), Clauses)).
-spec extract_from_function(tree()) -> tree().
extract_from_function(Function) ->
Clauses = erl_syntax:function_clauses(Function),
[Clause] = Clauses,
[Pattern] = erl_syntax:clause_patterns(Clause),
Pattern.
-spec get_records(atom()) -> [erl_parse:abstract_form()].
get_records(Module) ->
case code:which(Module) of
File when is_list(File) ->
case erl_prim_loader:get_file(File) of
{ok, Beam, File} ->
{ok, {_Mod, [{abstract_code, {_Version, Forms}}]}} = beam_lib:chunks(Beam, [
abstract_code
]),
[Record || {attribute, _, record, _} = Record <- Forms];
_ ->
[]
end;
_ ->
[]
end.
-spec extract_pattern([stacktrace_extrainfo()]) -> {ok, tree()} | {error, no_pattern}.
extract_pattern(ExtraInfo) ->
try
ErrorInfo = proplists:get_value(error_info, ExtraInfo),
#{pattern := PatternStr} = maps:get(cause, ErrorInfo),
{ok, Tokens, _} = erl_scan:string(PatternStr ++ "."),
{ok, [Pattern]} = erl_parse:parse_exprs(Tokens),
{ok, Pattern}
catch
_:_:_ ->
{error, no_pattern}
end.
-spec extract_bindings([stacktrace_extrainfo()]) -> bindings().
extract_bindings(ExtraInfo) ->
DefaultBindings = orddict:new(),
try
ErrorInfo = proplists:get_value(error_info, ExtraInfo),
#{pins := Pins} = maps:get(cause, ErrorInfo),
maps:fold(fun(Key, Value, Acc) -> orddict:store(Key, Value, Acc) end, DefaultBindings, Pins)
catch
_:_:_ ->
DefaultBindings
end.
-spec blame_mfa(module(), atom(), [term()]) -> blame_info().
blame_mfa(Module, Function, Args) ->
{ok, DebugInfo} = debug_info(Module),
Clauses = function_clauses({Module, Function, length(Args)}, DebugInfo),
{clauses, [blame_clause(Clause, Args) || Clause <- Clauses]}.
-spec blame_clause(tree(), [term()]) -> clause_info().
blame_clause(Clause, Args) ->
Patterns = erl_syntax:clause_patterns(Clause),
Guards = erl_syntax:clause_guard(Clause),
{PatternsInfo, Bindings} = blame_patterns(Patterns, Args),
GuardsInfo = blame_guards(Guards, Bindings),
{PatternsInfo, GuardsInfo}.
-spec blame_patterns([tree()], [term()]) -> {[pattern_info()], bindings()}.
blame_patterns(Patterns, Args) ->
{Info, Binding} = lists:foldl(
fun({Pattern, Arg}, {Info0, Bindings0}) ->
{PatternInfo, Bindings} = blame_pattern(Pattern, Arg, Bindings0),
{[PatternInfo | Info0], Bindings}
end,
{[], orddict:new()},
lists:zip(Patterns, Args)
),
{lists:reverse(Info), Binding}.
-spec blame_pattern(tree(), term(), bindings()) -> {pattern_info(), bindings()}.
blame_pattern(Pattern, Arg, Bindings0) ->
Bindings1 = orddict:store('$VAR$', Arg, Bindings0),
{IsMatch, NewBindings} =
try
case erl_eval:expr(match_expression(Pattern), Bindings1) of
{value, _Value, Bindings2} when not is_map(Bindings2) ->
{true, Bindings2}
end
catch
error:{badmatch, Map}:_S when is_map(Map) ->
blame_map(Pattern, Map, Bindings0);
error:{badmatch, Tuple}:_S when is_tuple(Tuple) ->
blame_tuple(Pattern, Tuple, Bindings0);
error:{badmatch, _}:_S ->
{false, Bindings1}
end,
{{IsMatch, Pattern}, NewBindings}.
-spec blame_map(tree(), map(), bindings()) -> {container_info(), bindings()}.
blame_map({map, _Anno, Fields}, Map, Bindings0) ->
Fun = fun(Field, {Acc, Bs}) ->
{IsMatch, NewBs} = blame_map_field(Field, Map, Bs),
{[{IsMatch, Field} | Acc], NewBs}
end,
{Info, Bindings} = lists:foldl(Fun, {[], Bindings0}, Fields),
{{map, lists:reverse(Info)}, Bindings}.
-spec blame_map_field(tree(), map(), bindings()) -> {pattern_info(), bindings()}.
blame_map_field({map_field_exact, _Anno, Key, Value}, Map, Bindings0) ->
try
case erl_eval:expr(Key, Bindings0) of
{value, EvaluatedKey, Bindings1} when not is_map(Bindings1) ->
MapValue = maps:get(EvaluatedKey, Map),
blame_pattern(Value, MapValue, Bindings1)
end
catch
error:{badkey, _}:_S ->
{{badkey, Value}, Bindings0};
error:_:_S ->
{{false, Value}, Bindings0}
end.
-spec blame_tuple(tree(), tuple(), bindings()) -> {container_info() | boolean(), bindings()}.
blame_tuple({tuple, _Anno, Fields}, Tuple, Bindings0) when length(Fields) =:= tuple_size(Tuple) ->
Fun = fun(Field, {Acc, Index, Bs}) ->
{IsMatch, NewBs} = blame_pattern(Field, element(Index, Tuple), Bs),
{[{IsMatch, Field} | Acc], Index + 1, NewBs}
end,
{Info, _Index, Bindings} = lists:foldl(Fun, {[], 1, Bindings0}, Fields),
{{tuple, lists:reverse(Info)}, Bindings};
blame_tuple(_Tree, _Tuple, Bindings) ->
{false, Bindings}.
-spec blame_guards(none | tree(), bindings()) -> [conjunction_info()].
blame_guards(none, _Bindings) ->
[];
blame_guards(Guards, Bindings) ->
case erl_syntax:type(Guards) of
conjunction ->
[blame_conjunction(erl_syntax:conjunction_body(Guards), Bindings)];
disjunction ->
DisjunctionBody = erl_syntax:disjunction_body(Guards),
[
blame_conjunction(erl_syntax:conjunction_body(Conjunction), Bindings)
|| Conjunction <- DisjunctionBody
]
end.
-spec blame_conjunction([tree()], bindings()) -> conjunction_info().
blame_conjunction(Guards, Bindings) ->
Fun = fun(Guard, {IsMatch0, Acc}) ->
IsMatch = blame_guard(Guard, Bindings, IsMatch0),
{IsMatch0 andalso IsMatch, [{IsMatch, Guard} | Acc]}
end,
{_Ignore, Acc} = lists:foldl(Fun, {true, []}, Guards),
lists:reverse(Acc).
-spec blame_guard(tree(), bindings(), boolean()) -> boolean().
blame_guard(Guard, Bindings, true = _IsMatch) ->
evaluate_guard(Guard, Bindings);
blame_guard(_Guard, _Bindings, false = _IsMatch) ->
true.
-spec evaluate_guard(dynamic(), bindings()) -> boolean().
evaluate_guard(Guard, Bindings) ->
try
case erl_eval:expr(Guard, Bindings) of
{value, Value, _Bindings} when is_boolean(Value) ->
Value
end
catch
error:{unbound_var, _}:_S ->
true
end.
-spec pp_blame(blame_info(), doc(), doc()) -> {ok, string()} | {error, term()}.
pp_blame({clauses, Clauses}, Pre, Post) ->
Doc = pp_blame_clauses(Clauses, Pre, Post),
pp_blame_doc(Doc);
pp_blame({generic, Patterns}, Pre, Post) ->
Doc = pp_blame_generic(Patterns, Pre, Post),
pp_blame_doc(Doc).
-spec pp_blame_generic([pattern_info()], doc(), doc()) -> doc().
pp_blame_generic(Patterns0, Pre, Post) ->
Patterns = [pp_blame_pattern_or_guard(Pattern, Pre, Post) || Pattern <- Patterns0],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<", ">>, Doc2)
end,
erlfmt_algebra:fold_doc(Break, Patterns).
-spec pp_blame_doc(doc()) -> {ok, string()} | {error, term()}.
pp_blame_doc(Doc) ->
case unicode:characters_to_list(erlfmt_algebra:format(Doc, 'infinity')) of
Error when is_tuple(Error) ->
{error, Error};
Res ->
{ok, Res}
end.
-spec pp_blame_clauses([clause_info()], doc(), doc()) -> doc().
pp_blame_clauses(Clauses0, Pre, Post) ->
Clauses = [pp_blame_clause(Clause, Pre, Post) || Clause <- Clauses0],
erlfmt_algebra:fold_doc(fun erlfmt_algebra:line/2, Clauses).
-spec pp_blame_clause(clause_info(), doc(), doc()) -> doc().
pp_blame_clause({Patterns0, Conjunctions0}, Pre, Post) ->
Patterns = pp_blame_clause_patterns(Patterns0, Pre, Post),
Clause =
case pp_blame_conjunctions(Conjunctions0, Pre, Post) of
doc_nil ->
Patterns;
Conjunctions ->
erlfmt_algebra:concat(Patterns, <<" when ">>, Conjunctions)
end,
erlfmt_algebra:group(Clause).
-spec pp_blame_clause_patterns([pattern_info()], doc(), doc()) -> doc().
pp_blame_clause_patterns(Patterns0, Pre, Post) ->
Patterns = [pp_blame_pattern_or_guard(Pattern, Pre, Post) || Pattern <- Patterns0],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<", ">>, Doc2)
end,
erlfmt_algebra:concat([
<<"(">>, erlfmt_algebra:fold_doc(Break, Patterns), <<")">>
]).
-spec pp_blame_conjunctions([conjunction_info()], doc(), doc()) -> doc().
pp_blame_conjunctions([], _Pre, _Post) ->
erlfmt_algebra:empty();
pp_blame_conjunctions(Conjunctions0, Pre, Post) ->
Conjunctions = [pp_blame_conjunction(Conjunction, Pre, Post) || Conjunction <- Conjunctions0],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<"; ">>, Doc2)
end,
erlfmt_algebra:fold_doc(Break, Conjunctions).
-spec pp_blame_conjunction(conjunction_info(), doc(), doc()) -> doc().
pp_blame_conjunction(Conjunction, Pre, Post) ->
Guards = [pp_blame_pattern_or_guard(Guard, Pre, Post) || Guard <- Conjunction],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<", ">>, Doc2)
end,
erlfmt_algebra:fold_doc(Break, Guards).
-spec pp_blame_pattern_or_guard(pattern_info() | guard_info(), doc(), doc()) -> doc().
pp_blame_pattern_or_guard({true, Pattern}, _Pre, _Post) ->
erlfmt_algebra:string(erl_prettypr:format(Pattern));
pp_blame_pattern_or_guard({false, Pattern}, Pre, Post) ->
Doc = erlfmt_algebra:string(erl_prettypr:format(Pattern)),
erlfmt_algebra:concat(Pre, Doc, Post);
pp_blame_pattern_or_guard({{map, ContainerFieldsInfo}, _Pattern}, Pre, Post) ->
pp_blame_map(ContainerFieldsInfo, Pre, Post);
pp_blame_pattern_or_guard({{tuple, ContainerFieldsInfo}, _Pattern}, Pre, Post) ->
pp_blame_tuple(ContainerFieldsInfo, Pre, Post).
-spec pp_blame_map([container_field_info()], doc(), doc()) -> doc().
pp_blame_map(Info, Pre, Post) ->
Patterns = [pp_blame_map_field(I, Pre, Post) || I <- Info],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<", ">>, Doc2)
end,
erlfmt_algebra:concat([
<<"#{">>, erlfmt_algebra:fold_doc(Break, Patterns), <<"}">>
]).
-spec pp_blame_map_field(container_field_info(), doc(), doc()) -> doc().
pp_blame_map_field({{badkey, _Value}, Pattern}, Pre, Post) ->
pp_blame_pattern_or_guard({false, Pattern}, Pre, Post);
pp_blame_map_field({Info, Pattern}, Pre, Post) ->
Name = erl_syntax:map_field_exact_name(Pattern),
erlfmt_algebra:concat([
erlfmt_algebra:string(erl_prettypr:format(Name)),
<<" := ">>,
pp_blame_pattern_or_guard(Info, Pre, Post)
]).
-spec pp_blame_tuple([container_field_info()], doc(), doc()) -> doc().
pp_blame_tuple(Info, Pre, Post) ->
Patterns = [pp_blame_pattern_or_guard(I, Pre, Post) || {I, _} <- Info],
Break = fun(Doc1, Doc2) ->
erlfmt_algebra:break(Doc1, <<", ">>, Doc2)
end,
erlfmt_algebra:concat([
<<"{">>, erlfmt_algebra:fold_doc(Break, Patterns), <<"}">>
]).
-spec error_info(term(), stacktrace()) -> {ok, error_info()} | {error, no_error_info}.
error_info(Reason, [{M, F, Args, _Meta} | _] = StackTrace) ->
try
case blame(Reason, StackTrace) of
{ok, Blame} ->
MFA = {M, F, length(Args)},
{ok, #{
module => ?MODULE,
function => format_error,
cause => #{blame => Blame, mfa => MFA, args => Args}
}};
{error, no_blame_info} ->
{error, no_error_info}
end
catch
C:R:S ->
?LOG_INFO(
"[wa_diff] Error while blaming. Please report this bug at https://github.com/whatsapp/assert.~n~ts",
[erl_error:format_exception(C, R, S)],
#{domain => wa_diff}
),
{error, no_error_info}
end.
-spec match_expression(tree()) -> tuple().
match_expression(Pattern) ->
Anno = erl_anno:new(0),
{match, Anno, Pattern, {var, Anno, '$VAR$'}}.
-spec debug_info(module()) -> {ok, debug_info()} | {error, term()}.
debug_info(Module) ->
case code:which(Module) of
File when is_list(File) ->
case erl_prim_loader:get_file(File) of
{ok, Beam, File} ->
case beam_lib:chunks(Beam, [debug_info]) of
{ok, {_Mod, [{debug_info, DebugInfo}]}} ->
{ok, DebugInfo};
E ->
{error, {no_debug_info, E}}
end;
E ->
{error, {file_not_found, E}}
end;
E ->
{error, {module_not_found, E}}
end.
-spec function_clauses(mfa(), debug_info()) -> [tree()].
function_clauses(MFA, DebugInfo) ->
{debug_info_v1 = _Version, erl_abstract_code = _Backend, Data} = DebugInfo,
{Forms, _Options} = Data,
lists:flatten([extract_function_clauses(Form, MFA) || Form <- Forms]).
-spec extract_function_clauses(tree(), mfa()) -> [tree()].
extract_function_clauses(Form, {_Module, Function, Arity} = _MFA) ->
case erl_syntax:type(Form) of
function ->
case
{
erl_syntax:concrete(erl_syntax:function_name(Form)),
erl_syntax:function_arity(Form)
}
of
{Function, Arity} ->
erl_syntax:function_clauses(Form);
_ ->
[]
end;
_ ->
[]
end.
-spec format_blame(term(), erlang:stacktrace()) -> ok.
format_blame(Reason, [{M, F, Args, _Meta} | _] = StackTrace) ->
try
case blame(Reason, StackTrace) of
{ok, BlameInfo} ->
{ok, PrettyReason} = pp_blame(BlameInfo, wa_diff:red(), wa_diff:reset()),
io:format("~ts~n", [format_reason(Reason, PrettyReason, M, F, Args)]);
{error, no_blame_info} ->
ok
end
catch
C:R:S ->
?LOG_INFO(
"[wa_diff] Error while formatting blame info. Please report this bug at https://github.com/whatsapp/assert.~n~ts",
[erl_error:format_exception(C, R, S)],
#{domain => wa_diff}
)
end;
format_blame(_Reason, _StackTrace) ->
ok.
-spec format_error(term(), erlang:stacktrace()) -> error_description().
format_error(R, [{_M, _F, _Args, Info} | _]) ->
ErrorInfo = proplists:get_value(error_info, Info, #{}),
Cause = maps:get(cause, ErrorInfo),
Blame = maps:get(blame, Cause),
{M, F, _A} = maps:get(mfa, Cause),
Args = maps:get(args, Cause),
case pp_blame(Blame, wa_diff:red(), wa_diff:reset()) of
{ok, Pretty} ->
Reason = format_reason(R, Pretty, M, F, Args),
Cause#{
general => "Diff", reason => Reason
};
{error, Error} ->
?LOG_INFO(
"[wa_diff] Error while pretty-printing blame info. Please report this bug at https://github.com/whatsapp/assert.~n~ts",
[Error],
#{domain => wa_diff}
),
Cause
end.
-spec format_reason(term(), string(), atom(), atom(), number() | [term()]) ->
string().
format_reason(function_clause, BlameInfo, M, F, Args) when is_list(Args) ->
format_function_clause_reason(BlameInfo, M, F, length(Args), Args);
format_reason({assertMatch, Reason}, BlameInfo, _M, _F, _Args) when is_list(Reason) ->
Value = proplists:get_value(value, Reason),
format_badmatch_reason(BlameInfo, Value).
-spec format_function_clause_reason(string(), atom(), atom(), non_neg_integer(), [term()]) -> string().
format_function_clause_reason(BlameInfo, M, F, A, Args) ->
Format =
"~n~nThe following arguments were given to the ~p:~p/~p function:~n"
"~n"
"~ts~n~n"
"But they did not match the following patterns:~n"
"~n"
"~ts~n",
lists:flatten(io_lib:format(Format, [M, F, A, format_args(Args), BlameInfo])).
-spec format_badmatch_reason(string(), term()) -> string().
format_badmatch_reason(BlameInfo, Value) ->
Format =
"~n~nMatch failed:~n~n"
"~ts~n~n"
"For the provided input:~n~n"
"~p~n~n",
lists:flatten(io_lib:format(Format, [BlameInfo, Value])).
-spec format_args([term()]) -> string().
format_args(Args) ->
string:join([format_arg(Arg) || Arg <- Args], "\n").
-spec format_arg(term()) -> string().
format_arg(Arg) ->
lists:flatten(io_lib:format("\t* ~p", [Arg])).