Skip to content

Commit

Permalink
refactor: cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
williamthome committed May 11, 2024
1 parent 821b707 commit d2ae4b8
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 226 deletions.
168 changes: 5 additions & 163 deletions src/doctest.erl
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,8 @@ Provides `module/1` and `module/2` to test doc attributes.
% API functions
-export([module/1, module/2]).

% Support functions
-export([ code_blocks/1
, chunk/1
, parse_mod/3
, parse_fun/3
, parse/4
, should_test_function/2
]).

% Check OTP version >= 27.
-include("doctest_otp_check.hrl").
-include_lib("kernel/include/eep48.hrl").

-type options() :: #{
moduledoc => boolean(),
Expand All @@ -55,166 +45,18 @@ module(Mod) ->
-spec module(module(), options()) -> test_result().

module(Mod, Opts) when is_atom(Mod), is_map(Opts) ->
case code:get_doc(Mod) of
{ok, #docs_v1{anno = Anno, module_doc = Lang, docs = Docs}} ->
Tests = [
moduledoc_tests(Mod, Anno, Lang, Opts),
doc_tests(Mod, Docs, Opts)
],
ShouldTestModDoc = maps:get(moduledoc, Opts, true),
FunsOpts = maps:get(funs, Opts, true),
case doctest_parse:module_tests(Mod, ShouldTestModDoc, FunsOpts) of
{ok, Tests} ->
EunitOpts = maps:get(eunit, Opts, default),
doctest_eunit:test(Tests, EunitOpts);
{error, Reason} ->
{error, Reason}
end.

%%%=====================================================================
%%% Support functions
%%%=====================================================================

code_blocks(Markdown) ->
Pattern = "(?ms)^(```[`]*)erlang\\s*\\n(.*?)(?:\\n^(\\1)(\\s+|\\n|$))",
Opts = [global, {capture, all_but_first, binary}],
case re:run(Markdown, Pattern, Opts) of
{match, Groups} ->
{ok, lists:map(fun([_, CodeBlock, _, _]) ->
CodeBlock
end, Groups)};
nomatch ->
none
end.

chunk(CodeBlock) ->
rev_normalize(join(split(CodeBlock)), []).

parse_mod(M, Ln, CodeBlocks) ->
parse(M, Ln, CodeBlocks, []).

parse_fun({M, F, A}, Ln, CodeBlocks) ->
parse(M, Ln, CodeBlocks, [{function, F}, {arity, A}]).

parse(Mod, Ln, CodeBlocks, ErrInfo) when
is_atom(Mod), is_integer(Ln), Ln > 0, is_list(CodeBlocks), is_list(ErrInfo) ->
element(2, lists:foldl(fun(CodeBlock, Acc) ->
lists:foldl(fun({Left, Right}, {Bindings, Acc1}) ->
{LeftValue, NewBindings} = eval(Left, Bindings),
{RightValue, []} = eval(Right, []),
Test = {Ln, fun() ->
case LeftValue =:= RightValue of
true ->
ok;
false ->
erlang:error({assertEqual, ErrInfo ++ [
{module, Mod},
{line, Ln},
{expression, Left},
{expected, RightValue},
{value, LeftValue}
]})
end
end},
{NewBindings, [Test | Acc1]}
end, {[], Acc}, chunk(CodeBlock))
end, [], CodeBlocks)).

should_test_function(true, _Fun) ->
true;
should_test_function(false, _Fun) ->
false;
should_test_function(Funs, Fun) when is_list(Funs) ->
lists:member(Fun, Funs).

%%%=====================================================================
%%% Internal functions
%%%=====================================================================

moduledoc_tests(Mod, Anno, Lang, Opts) ->
case maps:get(moduledoc, Opts, true) of
true ->
do_moduledoc_tests(Mod, Anno, Lang);
false ->
[]
end.

do_moduledoc_tests(Mod, Anno, Lang) ->
case code_blocks(unwrap_md(Lang)) of
{ok, CodeBlocks} ->
parse_mod(Mod, erl_anno:line(Anno), CodeBlocks);
none ->
[]
end.

doc_tests(Mod, Docs, Opts) ->
do_doc_tests(Mod, Docs, maps:get(funs, Opts, true)).

do_doc_tests(Mod, Docs, Funs) ->
lists:filtermap(fun({Type, Anno, _Sign, Lang, _Meta}) ->
case Type of
{function, Fun, Arity} ->
case should_test_function(Funs, {Fun, Arity}) of
true ->
% TODO: Check how to use shell_docs_markdown:parse_md/1.
% It can simplify the capture of the code blocks,
% but it's only available since OTP-27-rc3.
case code_blocks(unwrap_md(Lang)) of
{ok, CodeBlocks} ->
MFA = {Mod, Fun, Arity},
Ln = erl_anno:line(Anno),
{true, parse_fun(MFA, Ln, CodeBlocks)};
none ->
false
end;
false ->
false
end;
_ ->
false
end
end, Docs).

unwrap_md(#{<<"en">> := Md}) ->
Md.

split(CodeBlock) ->
binary:split(CodeBlock, [<<"\r">>, <<"\n">>, <<"\r\n">>], [global]).

join(Parts) ->
Opts = [{capture, all_but_first, binary}],
lists:foldl(fun(Part, Acc) ->
case re:run(Part, <<"^[0-9]+>\\s+(.*?)\\.*$">>, Opts) of
{match, [Left]} ->
[{left, Left} | Acc];
nomatch ->
case re:run(Part, <<"^\\.\\.\\s+(.*?)\\.*$">>, Opts) of
{match, [More]} ->
[{more, More} | Acc];
nomatch ->
[{right, Part} | Acc]
end
end
end, [], Parts).

% TODO: Maybe check for the correct line sequence by starting from 1, e.g.:
% 1> ok.
% 2> ok.
% And this should be wrong:
% 9> error.
% 8> error.
rev_normalize([{right, R}, {more, M}, {left, L} | T], Acc) ->
rev_normalize(T, [{<<L/binary, $\s, M/binary>>, R} | Acc]);
rev_normalize([{right, R}, {more, MR}, {more, ML} | T], Acc) ->
rev_normalize([{right, R}, {more, <<ML/binary, $\s, MR/binary>>} | T], Acc);
rev_normalize([{right, R}, {left, L} | T], Acc) ->
rev_normalize(T, [{L, R} | Acc]);
rev_normalize([], Acc) ->
Acc;
% Code block is not a test, e.g:
% foo() ->
% bar.
rev_normalize(_, _) ->
[].

eval(Bin, Bindings) ->
{ok, Tokens, _} = erl_scan:string(binary_to_list(<<Bin/binary, $.>>)),
{ok, Exprs} = erl_parse:parse_exprs(Tokens),
{value, Value, NewBindings} = erl_eval:exprs(Exprs, Bindings),
{Value, NewBindings}.
% nothing here yet!
81 changes: 80 additions & 1 deletion src/doctest_eunit.erl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
-moduledoc false.

% API functions
-export([test/1, test/2]).
-export([test/1, test/2, moduledoc_tests/3, doc_tests/3]).

%%%=====================================================================
%%% API functions
Expand All @@ -31,6 +31,12 @@ test(Tests, default) ->
test(Tests, Options) ->
eunit:test(Tests, Options).

moduledoc_tests(Mod, Ln, CodeBlocks) ->
tests(Mod, Ln, CodeBlocks, []).

doc_tests({M, F, A}, Ln, CodeBlocks) ->
tests(M, Ln, CodeBlocks, [{function, F}, {arity, A}]).

%%%=====================================================================
%%% Internal functions
%%%=====================================================================
Expand All @@ -43,6 +49,79 @@ options() ->
[]
end.

tests(Mod, Ln, CodeBlocks, ExtraErrInfo) when
is_atom(Mod), is_integer(Ln), Ln > 0,
is_list(CodeBlocks), is_list(ExtraErrInfo) ->
element(2, lists:foldl(fun(CodeBlock, Acc) ->
lists:foldl(fun({Left, Right}, {Bindings, Acc1}) ->
{LeftValue, NewBindings} = eval(Left, Bindings),
{RightValue, []} = eval(Right, []),
Test = {Ln, fun() ->
case LeftValue =:= RightValue of
true ->
ok;
false ->
erlang:error({assertEqual, ExtraErrInfo ++ [
{module, Mod},
{line, Ln},
{expression, Left},
{expected, RightValue},
{value, LeftValue}
]})
end
end},
{NewBindings, [Test | Acc1]}
end, {[], Acc}, code_block_asserts(CodeBlock))
end, [], CodeBlocks)).

code_block_asserts(CodeBlock) ->
asserts(chunks(lines(CodeBlock)), []).

lines(CodeBlock) ->
binary:split(CodeBlock, [<<"\r">>, <<"\n">>, <<"\r\n">>], [global]).

chunks(Parts) ->
Opts = [{capture, all_but_first, binary}],
lists:foldl(fun(Part, Acc) ->
case re:run(Part, <<"^[0-9]+>\\s+(.*?)\\.*$">>, Opts) of
{match, [Left]} ->
[{left, Left} | Acc];
nomatch ->
case re:run(Part, <<"^\\.\\.\\s+(.*?)\\.*$">>, Opts) of
{match, [More]} ->
[{more, More} | Acc];
nomatch ->
[{right, Part} | Acc]
end
end
end, [], Parts).

% TODO: Maybe check for the correct line sequence by starting from 1, e.g.:
% 1> ok.
% 2> ok.
% And this should be wrong:
% 9> error.
% 8> error.
asserts([{right, R}, {more, M}, {left, L} | T], Acc) ->
asserts(T, [{<<L/binary, $\s, M/binary>>, R} | Acc]);
asserts([{right, R}, {more, MR}, {more, ML} | T], Acc) ->
asserts([{right, R}, {more, <<ML/binary, $\s, MR/binary>>} | T], Acc);
asserts([{right, R}, {left, L} | T], Acc) ->
asserts(T, [{L, R} | Acc]);
asserts([], Acc) ->
Acc;
% Code block is not a test, e.g:
% foo() ->
% bar.
asserts(_, _) ->
[].

eval(Bin, Bindings) ->
{ok, Tokens, _} = erl_scan:string(binary_to_list(<<Bin/binary, $.>>)),
{ok, Exprs} = erl_parse:parse_exprs(Tokens),
{value, Value, NewBindings} = erl_eval:exprs(Exprs, Bindings),
{Value, NewBindings}.

%%%=====================================================================
%%% rebar3 non-exported functions
%%%=====================================================================
Expand Down
49 changes: 49 additions & 0 deletions src/doctest_md.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
%%%---------------------------------------------------------------------
%%% Copyright 2024 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_md).
-moduledoc false.

% API functions
-export([code_blocks/1]).

-define(CODE_BLOCK_RE,
"(?ms)^(```[`]*)erlang\\s*\\n" % ```erlang
"(.*?)" % <erlang-code>
"(?:\\n^(\\1)(\\s+|\\n|$))" % ```
).

%%%=====================================================================
%%% API functions
%%%=====================================================================

% TODO: Check how to use shell_docs_markdown:parse_md/1.
% It can simplify the capture of the code blocks,
% but it's only available since OTP-27-rc3.
code_blocks(Markdown) when is_list(Markdown); is_binary(Markdown) ->
case re:run(Markdown, ?CODE_BLOCK_RE, [
global, {capture, all_but_first, binary}
]) of
{match, Groups} ->
{ok, [CodeBlock || [_, CodeBlock, _, _] <- Groups]};
nomatch ->
none
end.

%%%=====================================================================
%%% Internal functions
%%%=====================================================================

% nothing here yet!
Loading

0 comments on commit d2ae4b8

Please sign in to comment.