%% %% %CopyrightBegin% %% %% Copyright Ericsson AB 2020-2023. All Rights Reserved. %% %% 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. %% %% %CopyrightEnd% %% -module(shell_docs_SUITE). -export([all/0, suite/0, groups/0, init_per_suite/1, end_per_suite/1, init_per_group/2, end_per_group/2]). -export([render/1, render_smoke/1, links/1, normalize/1, render_prop/1, render_non_native/1]). -export([render_all/1, update_render/0, update_render/1]). -include_lib("kernel/include/eep48.hrl"). -include_lib("stdlib/include/assert.hrl"). suite() -> [{timetrap,{minutes,20}}]. all() -> [render_smoke, render, render_non_native, links, normalize, {group, prop}]. groups() -> [{prop,[],[render_prop]}]. %% Include a spec here in order to test that specs of undocumented functions %% is rendered correctly. -spec init_per_suite(Config1) -> Config2 when Config1 :: list({atom(),term()}), Config2 :: list({atom(),term()}). init_per_suite(Config) -> {ok, ?MODULE} = c:c(?MODULE,[debug_info]), Config. end_per_suite(_Config) -> ok. init_per_group(prop, Config) -> ct_property_test:init_per_suite(Config); init_per_group(_GroupName, Config) -> Config. end_per_group(_GroupName, Config) -> Config. %% We keep the docs of a couple of complex modules %% in the data_dir in order to compare then with the original %% when we fix bugs so that we don't break anything. %% %% This testcase is always run so that we do now forget to %% check that any bugfix does not break the current behaviour. %% %% If you do a bugfix that does not break this testcase when %% fixed you should include that module in the list of modules %% tested. %% %% Currently the modules are: -define(RENDER_MODULES, [sofs, re, file, erlang, user_drv, ?MODULE]). %% If you need to update the definition because this %% testcase fails, just run update_render/0,1. render(Config) -> DataDir = proplists:get_value(data_dir, Config), lists:foreach( fun(Module) -> {ok, [D]} = file:consult(filename:join(DataDir, atom_to_list(Module) ++ ".docs_v1")), maps:map( fun(FName, Current) -> case file:read_file(filename:join(DataDir,FName)) of {ok, Original} when Original =:= Current -> ok; {ok, Original} -> ct:log("Original: ~n~ts",[Original]), ct:log("Current : ~n~ts",[Current]), ct:fail(output_changed); {error, enoent} -> %% All modules are not available on all %% platforms. For instance socket is not %% available on windows. ok end end, render_module(Module, D)) end, ?RENDER_MODULES). update_render() -> update_render( filename:join([os:getenv("ERL_TOP"), "lib", "stdlib", "test", "shell_docs_SUITE_data"])). update_render(DataDir) -> lists:foreach( fun(Module) -> case code:get_doc(Module) of {ok, D} -> ok = file:write_file( filename:join(DataDir, atom_to_list(Module) ++ ".docs_v1"), io_lib:format("~w.",[D])), maps:map( fun(FName, Output) -> ok = file:write_file(filename:join(DataDir, FName), Output) end, render_module(Module, D)); E -> io:format("Error processing: ~p ~p",[Module, E]) end end, ?RENDER_MODULES). render_smoke(_Config) -> docsmap( fun(Mod, #docs_v1{ docs = Docs } = D) -> lists:foreach( fun(Config) -> try E = fun({error,_}) -> ok; (Doc) -> unicode:characters_to_binary(Doc) end, E(shell_docs:render(Mod, D, Config)), E(shell_docs:render_type(Mod, D, Config)), E(shell_docs:render_callback(Mod, D, Config)), Exports = try Mod:module_info(exports) catch _:undef -> [] end, %% nif file not available on this platform [try E(shell_docs:render(Mod, F, A, D, Config)) catch _E:R:ST -> io:format("Failed to render ~p:~p/~p~n~p:~p~n~p~n", [Mod,F,A,R,ST,shell_docs:get_doc(Mod,F,A)]), erlang:raise(error,R,ST) end || {F,A} <- Exports], [try E(shell_docs:render_type(Mod, T, A, D, Config)) catch _E:R:ST -> io:format("Failed to render type ~p:~p/~p~n~p:~p~n~p~n", [Mod,T,A,R,ST,shell_docs:get_type_doc(Mod,T,A)]), erlang:raise(error,R,ST) end || {{type,T,A},_,_,_,_} <- Docs], [try E(shell_docs:render_callback(Mod, T, A, D, Config)) catch _E:R:ST -> io:format("Failed to render callback ~p:~p/~p~n~p:~p~n~p~n", [Mod,T,A,R,ST,shell_docs:get_callback_doc(Mod,T,A)]), erlang:raise(error,R,ST) end || {{callback,T,A},_,_,_,_} <- Docs] catch throw:R:ST -> io:format("Failed to render ~p~n~p:~p~n",[Mod,R,ST]), exit(R) end end, [#{}, #{ ansi => false }, #{ ansi => true }, #{ columns => 5 }, #{ columns => 150 }, #{ encoding => unicode }, #{ encoding => latin1 }]) end), ok. render_prop(Config) -> % dbg:tracer(),dbg:p(all,c),dbg:tpl(shell_docs_prop,[]), ct_property_test:quickcheck( shell_docs_prop:prop_render(),Config). links(_Config) -> docsmap( fun(Mod, #docs_v1{ module_doc = MDoc, docs = Docs }) -> try [check_links(Mod, maps:get(<<"en">>,MDoc)) || MDoc =/= none, MDoc =/= hidden] catch _E1:R1:ST1 -> io:format("Failed to render ~p~n~p:~p~n~p~n", [Mod,R1,ST1,MDoc]), erlang:raise(error,R1,ST1) end, [try check_links(Mod, D) catch _E:R:ST -> io:format("Failed to render ~p:~p~n~p:~p~n~p~n", [Mod,Kind,R,ST,D]), erlang:raise(error,R,ST) end || {Kind,_Anno,_Sig,#{ <<"en">> := D },_MD} <- Docs] end). check_links(Mod, [{a,Attr,C}|T]) -> case proplists:get_value(rel,Attr) of <<"https://erlang.org/doc/link/seemfa">> -> case string:lexemes(proplists:get_value(href, Attr),":#/") of [_App,TargetMod,Func,Arity] -> case code:get_doc(b2a(TargetMod)) of {ok, _ModDoc} -> case shell_docs:get_doc(b2a(TargetMod), b2a(Func), binary_to_integer(Arity)) of [] -> throw({could_not_find,b2a(TargetMod), b2a(Func), binary_to_integer(Arity)}); _Else -> ok end; _Else -> throw({could_not_find, TargetMod, Attr}) end; _Callback -> ok end; _OtherlinkType -> ok end, check_links(Mod, C), check_links(Mod, T); check_links(Mod, [{_,_,C}|T]) -> check_links(Mod, C), check_links(Mod, T); check_links(Mod, [C|T]) when is_binary(C) -> check_links(Mod, T); check_links(_, []) -> ok. normalize(_Config) -> ?assertMatch( [{p,[],[{em,[],[<<"a ">>,{code,[],[<<"b ">>]},<<"c">>]}]}], shell_docs:normalize([{p,[],[{em,[],[<<" a ">>,{code,[],[<<" b ">>]},<<" c">>]}]}]) ), ?assertMatch( [{'div',[],[<<"!">>]}], shell_docs:normalize([{'div',[],[{code,[],[<<" ">>,{i,[],[<<" ">>]}]},<<" !">>]}]) ), ok. %% Special binary_to_atom that deals with <<"'and'">> b2a(Bin) -> case erl_scan:string(binary_to_list(Bin)) of {ok,[{atom,_,A}],_} -> A; {ok,[{A,_}],_} -> A end. %% Test rendering of non-native modules render_non_native(_Config) -> Docs = #docs_v1{ anno = erl_anno:new(13), beam_language = not_erlang, format = <<"text/asciidoc">>, module_doc = #{<<"en">> => <<"This is\n\npure text">>}, docs = [] }, <<"\n\tnot_an_erlang_module\n\n" " This is\n" " \n" " pure text\n">> = unicode:characters_to_binary( shell_docs:render(not_an_erlang_module, Docs, #{ ansi => false })), ok. %% Testing functions render_all(Dir) -> file:make_dir(Dir), %% We load all possible application in order to be able to use %% application:get_application to get the application a module %% belongs to. PossibleApplications = lists:flatmap( fun(P) -> filelib:wildcard(filename:join(P, "*.app")) end, code:get_path()), [application:load(list_to_atom(filename:basename(filename:rootname(App)))) || App <- PossibleApplications], docsmap(fun(Mod, D) -> maps:map( fun(FName, Value) -> file:write_file(filename:join(Dir, FName), Value) end, render_module(Mod, D)) end). render_module(Mod, #docs_v1{ docs = Docs } = D) -> Opts = #{ ansi => true, columns => 80, encoding => unicode }, case application:get_application(Mod) of {ok, App} -> App; _ -> App = unknown end, SMod = atom_to_list(App) ++ "_" ++ atom_to_list(Mod), Files = #{ SMod ++ ".txt" => unicode:characters_to_binary(shell_docs:render(Mod, D, Opts)), SMod ++ "_type.txt" => unicode:characters_to_binary(shell_docs:render_type(Mod, D, Opts)), SMod ++ "_cb.txt" => unicode:characters_to_binary(shell_docs:render_callback(Mod, D, Opts)) }, lists:foldl( fun({_Type,_Anno,_Sig,none,_Meta}, Acc) -> Acc; ({{function,Name,Arity},_Anno,_Sig,_Doc,_Meta}, Acc) -> FName = SMod ++ "_"++atom_to_list(Name)++"_"++integer_to_list(Arity)++"_func.txt", Acc#{ sanitize(FName) => unicode:characters_to_binary(shell_docs:render(Mod, Name, Arity, D, Opts))}; ({{type,Name,Arity},_Anno,_Sig,_Doc,_Meta}, Acc) -> FName = SMod ++ "_"++atom_to_list(Name)++"_"++integer_to_list(Arity)++"_type.txt", Acc#{ sanitize(FName) => unicode:characters_to_binary(shell_docs:render_type(Mod, Name, Arity, D, Opts))}; ({{callback,Name,Arity},_Anno,_Sig,_Doc,_Meta}, Acc) -> FName = SMod ++ "_"++atom_to_list(Name)++"_"++integer_to_list(Arity)++"_cb.txt", Acc#{ sanitize(FName) => unicode:characters_to_binary(shell_docs:render_callback(Mod, Name, Arity, D, Opts))} end, Files, Docs). sanitize(FName) -> lists:foldl( fun({Re,Replace}, Txt) -> re:replace(Txt,Re,Replace,[global,{return,list}]) end, FName, [{"/","slash"},{":","colon"}, {"\\*","star"},{"<","lt"},{">","gt"},{"=","eq"}]). docsmap(Fun) -> lists:map( fun F({Mod,_,_}) -> F(Mod); F(Mod) when is_list(Mod) -> F(list_to_atom(Mod)); F(Mod) -> case code:get_doc(Mod) of {error, missing} -> ok; {error, cover_compiled} -> ok; {error, E} when E =:= eperm; E =:= eacces; E =:= eio -> %% This can happen in BSD's for some reason... ok; {error, eisdir} -> %% Uhm? ok; {ok, Docs} -> try Fun(Mod, Docs) catch E:R:ST -> io:format("Failed to render ~p~n~p:~p:~p~n",[Mod,E,R,ST]), erlang:raise(E,R,ST) end end end, code:all_available()).