1%% -*- erlang-indent-level: 4;indent-tabs-mode: nil -*- 2%% ex: ts=4 sw=4 et 3 4-module(rebar_prv_cover). 5 6-behaviour(provider). 7 8-export([init/1, 9 do/1, 10 maybe_cover_compile/1, 11 maybe_cover_compile/2, 12 maybe_write_coverdata/2, 13 format_error/1]). 14 15-include_lib("providers/include/providers.hrl"). 16-include("rebar.hrl"). 17 18-define(PROVIDER, cover). 19-define(DEPS, [lock]). 20 21%% =================================================================== 22%% Public API 23%% =================================================================== 24 25-spec init(rebar_state:t()) -> {ok, rebar_state:t()}. 26init(State) -> 27 State1 = rebar_state:add_provider(State, providers:create([{name, ?PROVIDER}, 28 {module, ?MODULE}, 29 {bare, true}, 30 {deps, ?DEPS}, 31 {example, "rebar3 cover"}, 32 {short_desc, "Perform coverage analysis."}, 33 {desc, "Perform coverage analysis."}, 34 {opts, cover_opts(State)}, 35 {profiles, [test]}])), 36 {ok, State1}. 37 38-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}. 39do(State) -> 40 {Opts, _} = rebar_state:command_parsed_args(State), 41 case proplists:get_value(reset, Opts, false) of 42 true -> reset(State); 43 false -> analyze(State) 44 end. 45 46-spec maybe_cover_compile(rebar_state:t()) -> ok. 47maybe_cover_compile(State) -> 48 maybe_cover_compile(State, apps). 49 50-spec maybe_cover_compile(rebar_state:t(), [file:name()] | apps) -> ok. 51maybe_cover_compile(State, Dirs) -> 52 case rebar_state:get(State, cover_enabled, false) of 53 true -> cover_compile(State, Dirs); 54 false -> ok 55 end. 56 57-spec maybe_write_coverdata(rebar_state:t(), atom()) -> ok. 58maybe_write_coverdata(State, Task) -> 59 case cover:modules() of 60 %% no coverdata collected, skip writing anything out 61 [] -> ok; 62 _ -> write_coverdata(State, Task) 63 end. 64 65-spec format_error(any()) -> iolist(). 66format_error({min_coverage_failed, {PassRate, Total}}) -> 67 io_lib:format("Requiring ~p% coverage to pass. Only ~p% obtained", 68 [PassRate, Total]); 69format_error(Reason) -> 70 io_lib:format("~p", [Reason]). 71 72%% =================================================================== 73%% Internal functions 74%% =================================================================== 75 76reset(State) -> 77 ?INFO("Resetting collected cover data...", []), 78 CoverDir = cover_dir(State), 79 CoverFiles = get_all_coverdata(CoverDir), 80 F = fun(File) -> 81 case file:delete(File) of 82 {error, Reason} -> 83 ?WARN("Error deleting ~p: ~p", [Reason, File]); 84 _ -> ok 85 end 86 end, 87 ok = lists:foreach(F, CoverFiles), 88 {ok, State}. 89 90analyze(State) -> 91 %% modules have to be compiled and then cover compiled 92 %% in order for cover data to be reloaded 93 %% this maybe breaks if modules have been deleted 94 %% since code coverage was collected? 95 {ok, S} = rebar_prv_compile:do(State), 96 ok = cover_compile(S, apps), 97 do_analyze(State). 98 99do_analyze(State) -> 100 ?INFO("Performing cover analysis...", []), 101 %% figure out what coverdata we have 102 CoverDir = cover_dir(State), 103 CoverFiles = get_all_coverdata(CoverDir), 104 %% start the cover server if necessary 105 {ok, CoverPid} = start_cover(), 106 %% redirect cover output 107 true = redirect_cover_output(State, CoverPid), 108 %% analyze! 109 case analyze(State, CoverFiles) of 110 [] -> {ok, State}; 111 Analysis -> 112 print_analysis(Analysis, verbose(State)), 113 write_index(State, Analysis), 114 maybe_fail_coverage(Analysis, State) 115 end. 116 117get_all_coverdata(CoverDir) -> 118 ok = filelib:ensure_dir(filename:join([CoverDir, "dummy.log"])), 119 {ok, Files} = rebar_utils:list_dir(CoverDir), 120 rebar_utils:filtermap(fun(FileName) -> 121 case filename:extension(FileName) == ".coverdata" of 122 true -> {true, filename:join([CoverDir, FileName])}; 123 false -> false 124 end 125 end, Files). 126 127analyze(_State, []) -> 128 ?WARN("No coverdata found", []), 129 []; 130analyze(State, CoverFiles) -> 131 %% reset any existing cover data 132 ok = cover:reset(), 133 %% import all coverdata files 134 ok = lists:foreach(fun(M) -> import(M) end, CoverFiles), 135 [{"aggregate", CoverFiles, analysis(State, "aggregate")}] ++ 136 analyze(State, CoverFiles, []). 137 138analyze(_State, [], Acc) -> lists:reverse(Acc); 139analyze(State, [F|Rest], Acc) -> 140 %% reset any existing cover data 141 ok = cover:reset(), 142 %% extract taskname from the CoverData file 143 Task = filename:basename(F, ".coverdata"), 144 %% import task cover data and process it 145 ok = import(F), 146 analyze(State, Rest, [{Task, [F], analysis(State, Task)}] ++ Acc). 147 148import(CoverData) -> 149 case cover:import(CoverData) of 150 {error, {cant_open_file, F, _Reason}} -> 151 ?WARN("Can't import cover data from ~ts.", [F]), 152 error; 153 ok -> ok 154 end. 155 156analysis(State, Task) -> 157 OldPath = code:get_path(), 158 ok = restore_cover_paths(State), 159 Mods = cover:imported_modules(), 160 Analysis = lists:map(fun(Mod) -> 161 {ok, Answer} = cover:analyze(Mod, coverage, line), 162 {ok, File} = analyze_to_file(Mod, State, Task), 163 {Mod, process(Answer), File} 164 end, 165 Mods), 166 true = rebar_utils:cleanup_code_path(OldPath), 167 lists:sort(Analysis). 168 169restore_cover_paths(State) -> 170 lists:foreach(fun(App) -> 171 AppDir = rebar_app_info:out_dir(App), 172 _ = code:add_path(filename:join([AppDir, "ebin"])), 173 _ = code:add_path(filename:join([AppDir, "test"])) 174 end, rebar_state:project_apps(State)), 175 _ = code:add_path(filename:join([rebar_dir:base_dir(State), "test"])), 176 ok. 177 178analyze_to_file(Mod, State, Task) -> 179 CoverDir = cover_dir(State), 180 TaskDir = filename:join([CoverDir, Task]), 181 ok = filelib:ensure_dir(filename:join([TaskDir, "dummy.html"])), 182 case code:ensure_loaded(Mod) of 183 {module, _} -> 184 write_file(Mod, mod_to_filename(TaskDir, Mod)); 185 {error, _} -> 186 ?WARN("Can't load module ~ts.", [Mod]), 187 {ok, []} 188 end. 189 190write_file(Mod, FileName) -> 191 case cover:analyze_to_file(Mod, FileName, [html]) of 192 {ok, File} -> {ok, File}; 193 {error, Reason} -> 194 ?WARN("Couldn't write annotated file for module ~p for reason ~p", [Mod, Reason]), 195 {ok, []} 196 end. 197 198mod_to_filename(TaskDir, M) -> 199 filename:join([TaskDir, atom_to_list(M) ++ ".html"]). 200 201process(Coverage) -> process(Coverage, {0, 0}). 202 203process([], Acc) -> Acc; 204%% line 0 is a line added by eunit and never executed so ignore it 205process([{{_, 0}, _}|Rest], Acc) -> process(Rest, Acc); 206process([{_, {Cov, Not}}|Rest], {Covered, NotCovered}) -> 207 process(Rest, {Covered + Cov, NotCovered + Not}). 208 209print_analysis(_, false) -> ok; 210print_analysis(Analysis, true) -> 211 {_, CoverFiles, Stats} = lists:keyfind("aggregate", 1, Analysis), 212 Table = format_table(Stats, CoverFiles), 213 io:format("~ts", [Table]). 214 215format_table(Stats, CoverFiles) -> 216 MaxLength = lists:max([20 | lists:map(fun({M, _, _}) -> mod_length(M) end, Stats)]), 217 Header = header(MaxLength), 218 Separator = separator(MaxLength), 219 TotalLabel = format("total", MaxLength), 220 TotalCov = format(calculate_total_string(Stats), 8), 221 [io_lib:format("~ts~n~ts~n~ts~n", [Separator, Header, Separator]), 222 lists:map(fun({Mod, Coverage, _}) -> 223 Name = format(Mod, MaxLength), 224 Cov = format(percentage_string(Coverage), 8), 225 io_lib:format(" | ~ts | ~ts |~n", [Name, Cov]) 226 end, Stats), 227 io_lib:format("~ts~n", [Separator]), 228 io_lib:format(" | ~ts | ~ts |~n", [TotalLabel, TotalCov]), 229 io_lib:format("~ts~n", [Separator]), 230 io_lib:format(" coverage calculated from:~n", []), 231 lists:map(fun(File) -> 232 io_lib:format(" ~ts~n", [File]) 233 end, CoverFiles)]. 234 235mod_length(Mod) when is_atom(Mod) -> mod_length(atom_to_list(Mod)); 236mod_length(Mod) -> length(Mod). 237 238header(Width) -> 239 [" | ", format("module", Width), " | ", format("coverage", 8), " |"]. 240 241separator(Width) -> 242 [" |--", io_lib:format("~*c", [Width, $-]), "--|------------|"]. 243 244format(String, Width) -> io_lib:format("~*.ts", [Width, String]). 245 246calculate_total_string(Stats) -> 247 integer_to_list(calculate_total(Stats))++"%". 248 249calculate_total(Stats) -> 250 percentage(lists:foldl( 251 fun({_Mod, {Cov, Not}, _File}, {CovAcc, NotAcc}) -> 252 {CovAcc + Cov, NotAcc + Not} 253 end, 254 {0, 0}, 255 Stats 256 )). 257 258percentage_string(Data) -> integer_to_list(percentage(Data))++"%". 259 260percentage({_, 0}) -> 100; 261percentage({Cov, Not}) -> trunc((Cov / (Cov + Not)) * 100). 262 263write_index(State, Coverage) -> 264 CoverDir = cover_dir(State), 265 FileName = filename:join([CoverDir, "index.html"]), 266 {ok, F} = file:open(FileName, [write]), 267 ok = file:write(F, "<!DOCTYPE HTML><html>\n" 268 "<head><meta charset=\"utf-8\">" 269 "<title>Coverage Summary</title></head>\n" 270 "<body>\n"), 271 {Aggregate, Rest} = lists:partition(fun({"aggregate", _, _}) -> true; (_) -> false end, 272 Coverage), 273 ok = write_index_section(F, Aggregate), 274 ok = write_index_section(F, Rest), 275 ok = file:write(F, "</body></html>"), 276 ok = file:close(F), 277 io:format(" cover summary written to: ~ts~n", [filename:absname(FileName)]). 278 279write_index_section(_F, []) -> ok; 280write_index_section(F, [{Section, DataFile, Mods}|Rest]) -> 281 %% Write the report 282 ok = file:write(F, ?FMT("<h1>~ts summary</h1>\n", [Section])), 283 ok = file:write(F, "coverage calculated from:\n<ul>"), 284 ok = lists:foreach(fun(D) -> ok = file:write(F, io_lib:format("<li>~ts</li>", [D])) end, 285 DataFile), 286 ok = file:write(F, "</ul>\n"), 287 ok = file:write(F, "<table><tr><th>module</th><th>coverage %</th></tr>\n"), 288 FmtLink = 289 fun({Mod, Cov, Report}) -> 290 ?FMT("<tr><td><a href='~ts'>~ts</a></td><td>~ts</td>\n", 291 [strip_coverdir(Report), Mod, percentage_string(Cov)]) 292 end, 293 lists:foreach(fun(M) -> ok = file:write(F, FmtLink(M)) end, Mods), 294 ok = file:write(F, ?FMT("<tr><td><strong>Total</strong></td><td>~ts</td>\n", 295 [calculate_total_string(Mods)])), 296 ok = file:write(F, "</table>\n"), 297 write_index_section(F, Rest). 298 299maybe_fail_coverage(Analysis, State) -> 300 {_, _CoverFiles, Stats} = lists:keyfind("aggregate", 1, Analysis), 301 Total = calculate_total(Stats), 302 PassRate = min_coverage(State), 303 ?DEBUG("Comparing ~p to pass rate ~p", [Total, PassRate]), 304 if Total >= PassRate -> 305 {ok, State} 306 ; Total < PassRate -> 307 ?PRV_ERROR({min_coverage_failed, {PassRate, Total}}) 308 end. 309 310%% fix for r15b which doesn't put the correct path in the `source` section 311%% of `module_info(compile)` 312strip_coverdir([]) -> ""; 313strip_coverdir(File) -> 314 filename:join(lists:reverse(lists:sublist(lists:reverse(filename:split(File)), 315 2))). 316 317cover_compile(State, apps) -> 318 ExclApps = [rebar_utils:to_binary(A) || A <- rebar_state:get(State, cover_excl_apps, [])], 319 Apps = filter_checkouts_and_excluded(rebar_state:project_apps(State), ExclApps), 320 AppDirs = app_dirs(Apps), 321 cover_compile(State, lists:filter(fun(D) -> ec_file:is_dir(D) end, AppDirs)); 322cover_compile(State, Dirs) -> 323 rebar_paths:set_paths([deps], State), 324 %% start the cover server if necessary 325 {ok, CoverPid} = start_cover(), 326 %% redirect cover output 327 true = redirect_cover_output(State, CoverPid), 328 ExclMods = rebar_state:get(State, cover_excl_mods, []), 329 ?DEBUG("Ignoring cover compilation of modules in {cover_excl_mods, ~p}", [ExclMods]), 330 lists:foreach(fun(Dir) -> 331 case file:list_dir(Dir) of 332 {ok, Files} -> 333 ?DEBUG("cover compiling ~p", [Dir]), 334 [cover_compile_file(filename:join(Dir, File)) 335 || File <- Files, 336 filename:extension(File) == ".beam", 337 not is_ignored(Dir, File, ExclMods)], 338 ok; 339 {error, eacces} -> 340 ?WARN("Directory ~p not readable, modules will not be included in coverage", [Dir]); 341 {error, enoent} -> 342 ?WARN("Directory ~p not found", [Dir]); 343 {error, Reason} -> 344 ?WARN("Directory ~p error ~p", [Dir, Reason]) 345 end 346 end, Dirs), 347 ok. 348 349is_ignored(Dir, File, ExclMods) -> 350 Ignored = lists:any(fun(Excl) -> 351 File =:= atom_to_list(Excl) ++ ".beam" 352 end, 353 ExclMods), 354 Ignored andalso ?DEBUG("cover ignoring ~p ~p", [Dir, File]), 355 Ignored. 356 357cover_compile_file(FileName) -> 358 case catch(cover:compile_beam(FileName)) of 359 {error, Reason} -> 360 ?WARN("Cover compilation failed: ~p", [Reason]); 361 {ok, _} -> 362 ok 363 end. 364 365app_dirs(Apps) -> 366 lists:foldl(fun app_ebin_dirs/2, [], Apps). 367 368app_ebin_dirs(App, Acc) -> 369 [rebar_app_info:ebin_dir(App)|Acc]. 370 371filter_checkouts_and_excluded(Apps, ExclApps) -> 372 filter_checkouts_and_excluded(Apps, ExclApps, []). 373 374filter_checkouts_and_excluded([], _ExclApps, Acc) -> lists:reverse(Acc); 375filter_checkouts_and_excluded([App|Rest], ExclApps, Acc) -> 376 case rebar_app_info:is_checkout(App) orelse lists:member(rebar_app_info:name(App), ExclApps) of 377 true -> filter_checkouts_and_excluded(Rest, ExclApps, Acc); 378 false -> filter_checkouts_and_excluded(Rest, ExclApps, [App|Acc]) 379 end. 380 381start_cover() -> 382 case cover:start() of 383 {ok, Pid} -> {ok, Pid}; 384 {error, {already_started, Pid}} -> {ok, Pid} 385 end. 386 387redirect_cover_output(State, CoverPid) -> 388 %% redirect cover console output to file 389 DataDir = cover_dir(State), 390 ok = filelib:ensure_dir(filename:join([DataDir, "dummy.log"])), 391 {ok, F} = file:open(filename:join([DataDir, "cover.log"]), 392 [append]), 393 group_leader(F, CoverPid). 394 395write_coverdata(State, Name) -> 396 DataDir = cover_dir(State), 397 ok = filelib:ensure_dir(filename:join([DataDir, "dummy.log"])), 398 ExportFile = filename:join([DataDir, rebar_utils:to_list(Name) ++ ".coverdata"]), 399 case cover:export(ExportFile) of 400 ok -> 401 %% dump accumulated coverdata after writing 402 ok = cover:reset(), 403 ?DEBUG("Cover data written to ~p.", [ExportFile]); 404 {error, Reason} -> 405 ?WARN("Cover data export failed: ~p", [Reason]) 406 end. 407 408command_line_opts(State) -> 409 {Opts, _} = rebar_state:command_parsed_args(State), 410 Opts. 411 412config_opts(State) -> 413 rebar_state:get(State, cover_opts, []). 414 415verbose(State) -> 416 Command = proplists:get_value(verbose, command_line_opts(State), undefined), 417 Config = proplists:get_value(verbose, config_opts(State), undefined), 418 case {Command, Config} of 419 {undefined, undefined} -> false; 420 {undefined, Verbose} -> Verbose; 421 {Verbose, _} -> Verbose 422 end. 423 424min_coverage(State) -> 425 Command = proplists:get_value(min_coverage, command_line_opts(State), undefined), 426 Config = proplists:get_value(min_coverage, config_opts(State), undefined), 427 case {Command, Config} of 428 {undefined, undefined} -> 0; 429 {undefined, Rate} -> Rate; 430 {Rate, _} -> Rate 431 end. 432 433cover_dir(State) -> 434 filename:join([rebar_dir:base_dir(State), "cover"]). 435 436cover_opts(_State) -> 437 [{reset, $r, "reset", boolean, help(reset)}, 438 {verbose, $v, "verbose", boolean, help(verbose)}, 439 {min_coverage, $m, "min_coverage", integer, help(min_coverage)}]. 440 441help(reset) -> "Reset all coverdata."; 442help(verbose) -> "Print coverage analysis."; 443help(min_coverage) -> "Mandate a coverage percentage required to succeed (0..100)". 444