%%%--------------------------------------------------------------------- %%% Copyright 2024-2025 William Fank Thomé %%% %%% 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(doctest_eunit). % API functions -export([test/2]). % Support functions -export([moduledoc_tests/5, doc_tests/5, test_title/2]). %%%===================================================================== %%% API functions %%%===================================================================== test(ignore, _Opts) -> ok; test(Tests, rebar3_config) -> eunit:test({inparallel, Tests}, rebar3_config_opts()); test(Tests, Options) when is_list(Options) -> eunit:test({inparallel, Tests}, Options). %%%===================================================================== %%% Support functions %%%===================================================================== moduledoc_tests(Mod, Bindings, AttrLn, CodeBlocks, Tag) -> case catch tests(Mod, Bindings, AttrLn, CodeBlocks, fun({Left, LeftLn, LeftValue}, {Right, RightLn, RightValue}) -> {desc(Tag, Mod, LeftLn), {Mod, moduledoc, 0}, fun() -> case LeftValue of RightValue -> ok; _ -> error({assertEqual, [ {doctest, #{ attribute => moduledoc, tag => Tag, left => {Left, LeftLn, LeftValue}, right => {Right, RightLn, RightValue}, ln_range => {LeftLn, RightLn} }}, {module, Mod}, {line, LeftLn}, {expression, Left}, {expected, RightValue}, {value, LeftValue} ]}) end end} end ) of {ok, Tests} -> Tests; {error, {format, ErrInfo}} -> error({doctest, format}, [Mod, AttrLn, CodeBlocks], [ {error_info, ErrInfo#{ attribute => moduledoc, module => Mod, cause => format }} ]); {error, {eval, Expr, Ln, Bindings, Reason}} -> error({doctest, {eval, Expr, Ln, Bindings}}, [Mod, AttrLn, CodeBlocks], [ {error_info, #{ attribute => moduledoc, module => Mod, expression => Expr, bindings => Bindings, cause => Reason, line => Ln }} ]); {error, {parse, Expr, Ln, Reason}} -> error({doctest, {eval, Expr, Ln}}, [Mod, AttrLn, CodeBlocks], [ {error_info, #{ attribute => moduledoc, module => Mod, expression => Expr, cause => Reason, line => Ln }} ]) end. doc_tests({M, F, A}, Bindings, AttrLn, CodeBlocks, Tag) -> case catch tests(M, Bindings, AttrLn, CodeBlocks, fun({Left, LeftLn, LeftValue}, {Right, RightLn, RightValue}) -> {desc(Tag, M, LeftLn), {M, F, A}, fun() -> case LeftValue of RightValue -> ok; _ -> error({assertEqual, [ {doctest, #{ attribute => doc, tag => Tag, left => {Left, LeftLn, LeftValue}, right => {Right, RightLn, RightValue}, ln_range => {LeftLn, RightLn} }}, {module, M}, {function, F}, {arity, A}, {line, LeftLn}, {expression, Left}, {expected, RightValue}, {value, LeftValue} ]}) end end} end ) of {ok, Tests} -> Tests; {error, {format, ErrInfo}} -> error({doctest, format}, [{M, F, A}, AttrLn, CodeBlocks], [ {error_info, ErrInfo#{ attribute => doc, module => M, function => F, arity => A, cause => format }} ]); {error, {eval, Expr, Ln, Bindings, Reason}} -> error({doctest, {eval, Expr, Ln, Bindings}}, [{M, F, A}, AttrLn, CodeBlocks], [ {error_info, #{ attribute => doc, module => M, function => F, arity => A, expression => Expr, bindings => Bindings, cause => Reason, line => Ln }} ]); {error, {parse, Expr, Ln, Reason}} -> error({doctest, {parse, Expr}}, [{M, F, A}, AttrLn, CodeBlocks], [ {error_info, #{ attribute => doc, module => M, function => F, arity => A, expression => Expr, cause => Reason, line => Ln }} ]) end. test_title(Mod, Ln) -> Filename = proplists:get_value(source, Mod:module_info(compile), code:which(Mod)), case string:split(Filename, filename:absname("./")) of [[], Rel] -> iolist_to_binary(io_lib:format(".~s:~p", [Rel, Ln])); Rel -> iolist_to_binary(io_lib:format(".~s:~p", [Rel, Ln])) end. %%%===================================================================== %%% Internal functions %%%===================================================================== rebar3_config_opts() -> case erlang:module_loaded(rebar3) of true -> resolve_eunit_opts(rebar3:init_config()); false -> [] end. tests(Mod, Bindings, AttrLn, CodeBlocks, Callback) when is_atom(Mod), (is_map(Bindings) orelse is_list(Bindings)), is_integer(AttrLn), AttrLn >= 0, is_list(CodeBlocks), is_function(Callback, 2) -> {ok, lists:foldl(fun({CodeBlock, {CBLn, _CBCol}}, Acc) -> case code_block_asserts(CodeBlock, AttrLn + CBLn) of {ok, Asserts} -> lists:reverse(element(2, lists:foldl( fun({{Left, LeftLn}, {Right, RightLn}}, {BindingsAcc, Acc1}) -> LocalFunctionHandler = {value, fun(Name, Args) -> erlang:apply(Mod, Name, Args) end}, {LeftValue, NewBindings} = eval(Left, LeftLn, BindingsAcc, LocalFunctionHandler), % Skip when no right value case Right of [{var, _, '_'}] -> {NewBindings, Acc1}; _ -> {RightValue, _} = eval(Right, RightLn, []), Test = Callback( {pp(Left), LeftLn, LeftValue}, {pp(Right), RightLn, RightValue} ), {NewBindings, [Test | Acc1]} end end, {Bindings, Acc}, lists:reverse(Asserts) ))); {error, Reason} -> throw({error, Reason}) end end, [], CodeBlocks)}. desc(Tag, Mod, Ln) -> iolist_to_binary(io_lib:format("doctest ~s\s~s", [Tag, test_title(Mod, Ln)])). eval(Exprs, Ln, Bindings) -> eval(Exprs, Ln, Bindings, none). eval(Exprs, Ln, Bindings, LocalFunctionHandler) -> try {value, Value, NewBindings} = erl_eval:exprs(Exprs, Bindings, LocalFunctionHandler), {Value, NewBindings} catch _Class:Reason:Stacktrace -> throw({error, {eval, pp(Exprs), Ln, Bindings, {Reason, Stacktrace}}}) end. pp(Exprs) -> iolist_to_binary(erl_pp:exprs(Exprs)). code_block_asserts(CodeBlock, InitLn) -> case chunks(split_lines(CodeBlock)) of [] -> []; [H|_] = Chunks -> asserts(Chunks, {H, 1}, {InitLn, InitLn}, []) end. split_lines(CodeBlock) -> binary:split(CodeBlock, [<<"\r">>, <<"\n">>, <<"\r\n">>], [global]). chunks(Parts) -> {ok, ReExpr} = re:compile(<<"^([1-9][0-9]*)?>\\s(.*?)\\.*$">>), {ok, ReMoreExpr} = re:compile(<<"^(\\s*)(\\.*)\\s(.*?)\\.*$">>), do_chunks(Parts, ReExpr, ReMoreExpr). do_chunks([], _ReExpr, _ReMoreExpr) -> []; do_chunks([H | T], ReExpr, ReMoreExpr) -> case do_chunk(H, ReExpr, ReMoreExpr) of {right, R0} -> {R, T1} = find_right_side_end(T, ReExpr, R0), [{right, R} | do_chunks(T1, ReExpr, ReMoreExpr)]; Chunk -> [Chunk | do_chunks(T, ReExpr, ReMoreExpr)] end. do_chunk(Part, ReExpr, ReMoreExpr) -> ReOpts = [{capture, all_but_first, binary}], case re:run(Part, ReExpr, ReOpts) of {match, [<<>>, Left]} -> {left, {undefined, Left}}; {match, [N, Left]} -> {left, {N, Left}}; nomatch -> case re:run(Part, ReMoreExpr, ReOpts) of {match, [Ws, Dots, More]} -> {more, {Ws, Dots, More}}; nomatch -> {right, Part} end end. find_right_side_end([], _ReExpr, Acc) -> {Acc, []}; find_right_side_end([H | T] = All, ReExpr, Acc) -> case re:run(H, ReExpr, [{capture, none}]) of match -> {Acc, All}; nomatch -> find_right_side_end(T, ReExpr, <>) end. asserts([{left, {N, L}}, {more, {Ws, Dots, M}} | T], HI, {Ln, NLn}, Acc) -> case check_more_format(N, Ws) of ok -> asserts([{left, {N, <>}} | T], HI, {Ln, NLn+1}, Acc); {error, {EWs, RWs}} -> Expected = iolist_to_binary([lists:duplicate(EWs, "\s"), Dots, "> ", M]), Received = iolist_to_binary([lists:duplicate(RWs, "\s"), Dots, "> ", M]), {error, {format, #{ line => NLn+1, expected => Expected, received => Received }}} end; asserts([{left, {N, L}}, {right, R} | T], {{left, {_, H}}, I}, {Ln, NLn}, Acc) -> case check_left_index(N, I) of ok when T =:= [] -> {ok, [{{parse(L, Ln), Ln}, {parse(R, NLn+1), NLn+1}} | Acc]}; ok -> asserts(T, {hd(T), I+1}, {NLn+2, NLn+2}, [{{parse(L, Ln), Ln}, {parse(R, NLn+1), NLn+1}} | Acc]); error -> Expected = iolist_to_binary([integer_to_binary(I), "> ", H]), Received = iolist_to_binary([case N of undefined -> <<>>; _ -> N end, "> ", H]), {error, {format, #{ line => Ln, expected => Expected, received => Received }}} end; asserts([{left, _} = Left, {left, _} = Next | T], HI, {Ln, NLn}, Acc) -> asserts([Left, {right, <<"_">>}, Next | T], HI, {Ln-1, NLn-1}, Acc); % Code block is not a test, e.g: % foo() -> % bar. asserts(_, _, _, _) -> {ok, []}. parse(Expr, Ln) -> try {ok, Tokens, _} = erl_scan:string(binary_to_list(<>)), {ok, Ast} = erl_parse:parse_exprs(Tokens), Ast catch error:ErrInfo:Stacktrace -> throw({error, {parse, Expr, Ln, {ErrInfo, Stacktrace}}}) end. check_more_format(undefined, _Ws) -> ok; check_more_format(Ln, Ws) -> LnSz = max(0, byte_size(Ln) - 1), WsSz = byte_size(Ws), case WsSz =:= LnSz of true -> ok; false -> {error, {LnSz, WsSz}} end. check_left_index(undefined, _Ln) -> ok; check_left_index(N, Ln) -> case catch binary_to_integer(N) =:= Ln of true -> ok; _ -> error end. %%%===================================================================== %%% rebar3 non-exported functions %%%===================================================================== % TODO: Maybe submit a PR exporting 'resolve_eunit_opts/1'. % See https://github.com/erlang/rebar3/blob/b64d94f4e6fb738c4a3004faf833e0b9617d86a8/apps/rebar/src/rebar_prv_eunit.erl#L443 resolve_eunit_opts(State) -> {Opts, _} = rebar_state:command_parsed_args(State), EUnitOpts = rebar_state:get(State, eunit_opts, []), EUnitOpts1 = case proplists:get_value(verbose, Opts, false) of true -> set_verbose(EUnitOpts); false -> EUnitOpts end, EUnitOpts2 = case proplists:get_value(profile, Opts, false) of true -> set_profile(EUnitOpts1); false -> EUnitOpts1 end, IsVerbose = lists:member(verbose, EUnitOpts2), case proplists:get_value(eunit_formatters, Opts, not IsVerbose) of true -> custom_eunit_formatters(EUnitOpts2); false -> EUnitOpts2 end. custom_eunit_formatters(Opts) -> ReportOpts = custom_eunit_report_options(Opts), %% If `report` is already set then treat that like `eunit_formatters` is false case lists:keymember(report, 1, Opts) of true -> Opts; false -> [no_tty, {report, {eunit_progress, ReportOpts}} | Opts] end. custom_eunit_report_options(Opts) -> case lists:member(profile, Opts) of true -> [colored, profile]; false -> [colored] end. set_profile(Opts) -> %% if `profile` is already set don't set it again case lists:member(profile, Opts) of true -> Opts; false -> [profile] ++ Opts end. set_verbose(Opts) -> %% if `verbose` is already set don't set it again case lists:member(verbose, Opts) of true -> Opts; false -> [verbose] ++ Opts end.