%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net> %% %% Permission is hereby granted, free of charge, to any person %% obtaining a copy of this software and associated documentation %% files (the "Software"), to deal in the Software without %% restriction, including without limitation the rights to use, %% copy, modify, merge, publish, distribute, sublicense, and/or sell %% copies of the Software, and to permit persons to whom the %% Software is furnished to do so, subject to the following %% conditions: %% %% The above copyright notice and this permission notice shall be %% included in all copies or substantial portions of the Software. %% %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, %% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES %% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND %% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT %% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, %% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR %% OTHER DEALINGS IN THE SOFTWARE. %% %% @doc A module for creating nice looking code coverage reports. -module(etap_report). -export([create/0]). %% @spec create() -> ok %% @doc Create html code coverage reports for each module that code coverage %% data exists for. create() -> [cover:import(File) || File <- filelib:wildcard("cover/*.coverdata")], Modules = lists:foldl( fun(Module, Acc) -> [{Module, file_report(Module)} | Acc] end, [], cover:imported_modules() ), index(Modules). %% @private index(Modules) -> {ok, IndexFD} = file:open("cover/index.html", [write]), io:format(IndexFD, "<html><head><style> table.percent_graph { height: 12px; border:1px solid #E2E6EF; empty-cells: show; } table.percent_graph td.covered { height: 10px; background: #00f000; } table.percent_graph td.uncovered { height: 10px; background: #e00000; } .odd { background-color: #ddd; } .even { background-color: #fff; } </style></head>", []), io:format(IndexFD, "<body>", []), lists:foldl( fun({Module, {Good, Bad, Source}}, LastRow) -> case {Good + Bad, Source} of {0, _} -> LastRow; {_, none} -> LastRow; _ -> CovPer = round((Good / (Good + Bad)) * 100), UnCovPer = round((Bad / (Good + Bad)) * 100), RowClass = case LastRow of 1 -> "odd"; _ -> "even" end, io:format(IndexFD, "<div class=\"~s\">", [RowClass]), io:format(IndexFD, "<a href=\"~s\">~s</a>", [atom_to_list(Module) ++ "_report.html", atom_to_list(Module)]), io:format(IndexFD, " <table cellspacing='0' cellpadding='0' align='right'> <tr> <td><tt>~p%</tt> </td><td> <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'> <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr> </table> </td> </tr> </table> ", [CovPer, CovPer, UnCovPer]), io:format(IndexFD, "</div>", []), case LastRow of 1 -> 0; 0 -> 1 end end end, 0, lists:sort(Modules) ), {TotalGood, TotalBad} = lists:foldl( fun({_, {Good, Bad, Source}}, {TGood, TBad}) -> case Source of none -> {TGood, TBad}; _ -> {TGood + Good, TBad + Bad} end end, {0, 0}, Modules ), io:format(IndexFD, "<p>Generated on ~s.</p>~n", [etap:datetime({date(), time()})]), case TotalGood + TotalBad of 0 -> ok; _ -> TotalCovPer = round((TotalGood / (TotalGood + TotalBad)) * 100), TotalUnCovPer = round((TotalBad / (TotalGood + TotalBad)) * 100), io:format(IndexFD, "<div>", []), io:format(IndexFD, "Total <table cellspacing='0' cellpadding='0' align='right'> <tr> <td><tt>~p%</tt> </td><td> <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'> <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr> </table> </td> </tr> </table> ", [TotalCovPer, TotalCovPer, TotalUnCovPer]), io:format(IndexFD, "</div>", []) end, io:format(IndexFD, "</body></html>", []), file:close(IndexFD), ok. %% @private file_report(Module) -> {ok, Data} = cover:analyse(Module, calls, line), Source = find_source(Module), {Good, Bad} = collect_coverage(Data, {0, 0}), case {Source, Good + Bad} of {none, _} -> ok; {_, 0} -> ok; _ -> {ok, SourceFD} = file:open(Source, [read]), {ok, WriteFD} = file:open("cover/" ++ atom_to_list(Module) ++ "_report.html", [write]), io:format(WriteFD, "~s", [header(Module, Good, Bad)]), output_lines(Data, WriteFD, SourceFD, 1), io:format(WriteFD, "~s", [footer()]), file:close(WriteFD), file:close(SourceFD), ok end, {Good, Bad, Source}. %% @private collect_coverage([], Acc) -> Acc; collect_coverage([{{_, _}, 0} | Data], {Good, Bad}) -> collect_coverage(Data, {Good, Bad + 1}); collect_coverage([_ | Data], {Good, Bad}) -> collect_coverage(Data, {Good + 1, Bad}). %% @private output_lines(Data, WriteFD, SourceFD, LineNumber) -> {Match, NextData} = datas_match(Data, LineNumber), case io:get_line(SourceFD, '') of eof -> ok; Line = "%% @todo" ++ _ -> io:format(WriteFD, "~s", [out_line(LineNumber, highlight, Line)]), output_lines(NextData, WriteFD, SourceFD, LineNumber + 1); Line = "% " ++ _ -> io:format(WriteFD, "~s", [out_line(LineNumber, none, Line)]), output_lines(NextData, WriteFD, SourceFD, LineNumber + 1); Line -> case Match of {true, CC} -> io:format(WriteFD, "~s", [out_line(LineNumber, CC, Line)]), output_lines(NextData, WriteFD, SourceFD, LineNumber + 1); false -> io:format(WriteFD, "~s", [out_line(LineNumber, none, Line)]), output_lines(NextData, WriteFD, SourceFD, LineNumber + 1) end end. %% @private out_line(Number, none, Line) -> PadNu = string:right(integer_to_list(Number), 5, $.), io_lib:format("<span class=\"marked\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]); out_line(Number, highlight, Line) -> PadNu = string:right(integer_to_list(Number), 5, $.), io_lib:format("<span class=\"highlight\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]); out_line(Number, 0, Line) -> PadNu = string:right(integer_to_list(Number), 5, $.), io_lib:format("<span class=\"uncovered\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]); out_line(Number, _, Line) -> PadNu = string:right(integer_to_list(Number), 5, $.), io_lib:format("<span class=\"covered\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]). %% @private datas_match([], _) -> {false, []}; datas_match([{{_, Line}, CC} | Datas], LineNumber) when Line == LineNumber -> {{true, CC}, Datas}; datas_match(Data, _) -> {false, Data}. %% @private find_source(Module) when is_atom(Module) -> Root = filename:rootname(Module), Dir = filename:dirname(Root), XDir = case os:getenv("SRC") of false -> "src"; X -> X end, find_source([ filename:join([Dir, Root ++ ".erl"]), filename:join([Dir, "..", "src", Root ++ ".erl"]), filename:join([Dir, "src", Root ++ ".erl"]), filename:join([Dir, "elibs", Root ++ ".erl"]), filename:join([Dir, "..", "elibs", Root ++ ".erl"]), filename:join([Dir, XDir, Root ++ ".erl"]) ]); find_source([]) -> none; find_source([Test | Tests]) -> case filelib:is_file(Test) of true -> Test; false -> find_source(Tests) end. %% @private header(Module, Good, Bad) -> io:format("Good ~p~n", [Good]), io:format("Bad ~p~n", [Bad]), CovPer = round((Good / (Good + Bad)) * 100), UnCovPer = round((Bad / (Good + Bad)) * 100), io:format("CovPer ~p~n", [CovPer]), io_lib:format("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\"> <html lang='en' xml:lang='en' xmlns='http://www.w3.org/1999/xhtml'> <head> <title>~s - C0 code coverage information</title> <style type='text/css'>body { background-color: rgb(240, 240, 245); }</style> <style type='text/css'>span.marked0 { background-color: rgb(185, 210, 200); display: block; } span.marked { display: block; background-color: #ffffff; } span.highlight { display: block; background-color: #fff9d7; } span.covered { display: block; background-color: #f7f7f7 ; } span.uncovered { display: block; background-color: #ffebe8 ; } span.overview { border-bottom: 1px solid #E2E6EF; } div.overview { border-bottom: 1px solid #E2E6EF; } body { font-family: verdana, arial, helvetica; } div.footer { font-size: 68%; margin-top: 1.5em; } h1, h2, h3, h4, h5, h6 { margin-bottom: 0.5em; } h5 { margin-top: 0.5em; } .hidden { display: none; } div.separator { height: 10px; } table.percent_graph { height: 12px; border: 1px solid #E2E6EF; empty-cells: show; } table.percent_graph td.covered { height: 10px; background: #00f000; } table.percent_graph td.uncovered { height: 10px; background: #e00000; } table.percent_graph td.NA { height: 10px; background: #eaeaea; } table.report { border-collapse: collapse; width: 100%; } table.report td.heading { background: #dcecff; border: 1px solid #E2E6EF; font-weight: bold; text-align: center; } table.report td.heading:hover { background: #c0ffc0; } table.report td.text { border: 1px solid #E2E6EF; } table.report td.value { text-align: right; border: 1px solid #E2E6EF; } table.report tr.light { background-color: rgb(240, 240, 245); } table.report tr.dark { background-color: rgb(230, 230, 235); } </style> </head> <body> <h3>C0 code coverage information</h3> <p>Generated on ~s with <a href='http://github.com/ngerakines/etap'>etap 0.3.4</a>. </p> <table class='report'> <thead> <tr> <td class='heading'>Name</td> <td class='heading'>Total lines</td> <td class='heading'>Lines of code</td> <td class='heading'>Total coverage</td> <td class='heading'>Code coverage</td> </tr> </thead> <tbody> <tr class='light'> <td> <a href='~s'>~s</a> </td> <td class='value'> <tt>??</tt> </td> <td class='value'> <tt>??</tt> </td> <td class='value'> <tt>??</tt> </td> <td> <table cellspacing='0' cellpadding='0' align='right'> <tr> <td><tt>~p%</tt> </td><td> <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'> <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr> </table> </td> </tr> </table> </td> </tr> </tbody> </table><pre>", [Module, etap:datetime({date(), time()}), atom_to_list(Module) ++ "_report.html", Module, CovPer, CovPer, UnCovPer]). %% @private footer() -> "</pre><hr /><p>Generated using <a href='http://github.com/ngerakines/etap'>etap 0.3.4</a>.</p> </body> </html> ".