-module(covertool). %% command line entry point for escriptize -export([main/1]). %% application entry point -export([generate_report/2]). -include("covertool.hrl"). -include_lib("xmerl/include/xmerl.hrl"). -define(EUNIT_DIR, ".eunit"). -record(result, {line = {0, 0}, branches = {0, 0}, data = []}). main([]) -> usage(); main(Args) -> Config = process_args(Args, #config{appname = 'Application', sources = ["src/"], beams = ["ebin/"]}), CoverData = Config#config.cover_data, io:format("Importing '~s' data file...~n", [CoverData]), cover:import(CoverData), io:format("Found ~w modules.~n", [length(cover:imported_modules())]), generate_report(Config, cover:imported_modules()), io:format("Done.~n"), ok. usage() -> ScriptName = escript:script_name(), io:format("Usage: ~s [Options]~n", [ScriptName]), io:format("Options:~n"), io:format(" -cover CoverDataFile Path to the cover exported data set (default: \"all.coverdata\")~n"), io:format(" -output OutputFile File to put generated report to (default: \"coverage.xml\")~n"), io:format(" -src SourceDir Directory to look for sources (default: \"src\")~n"), io:format(" -ebin EbinDir Directory to look for beams (default: \"ebin\")~n"), io:format(" -prefix PrefixLen Length used for package name (default: 0)~n"), io:format(" -appname AppName Application name to put in the report (default: \"Application\")~n"), ok. % Parse arguments into record process_args([], Config) -> Config; process_args([[$- | Name] , Value | Args], Config) -> NameAtom = list_to_atom(Name), process_args(Args, update_config(NameAtom, Value, Config)); process_args(_Args, _Config) -> usage(), halt(1). update_config(cover, Value, Config) -> Config#config{cover_data = Value}; update_config(output, Value, Config) -> Config#config{output = Value}; update_config(src, Value, Config) -> Config#config{sources = string:tokens(Value, ",")}; update_config(ebin, Value, Config) -> Config#config{beams = string:tokens(Value, ",")}; update_config(prefix, Value, Config) -> Config#config{prefix_len = list_to_integer(Value)}; update_config(appname, Value, Config) -> Config#config{appname = list_to_atom(Value)}; update_config(_Other, _Value, _Config) -> usage(), halt(1). generate_report(Config, Modules) -> AppName = Config#config.appname, PrefixLen = Config#config.prefix_len, Output = Config#config.output, case Config#config.cover_data of no_import -> ok; CoverData -> cover:import(CoverData) end, put(src, Config#config.sources), put(ebin, Config#config.beams), io:format("Generating report '~s'...~n", [Output]), Prolog = ["\n", "\n"], {MegaSecs, Secs, MicroSecs} = os:timestamp(), Timestamp = MegaSecs * 1000000000 + Secs * 1000 + (MicroSecs div 1000), % in milliseconds Version = "1.9.4.1", % emulate Cobertura 1.9.4.1 Complexity = 0, % not supported at the moment Result = generate_packages(AppName, PrefixLen, Modules), {LinesCovered, LinesValid} = Result#result.line, LineRate = rate(Result#result.line), {BranchesCovered, BranchesValid} = Result#result.branches, BranchRate = rate(Result#result.branches), Sources = [{source, [filename:absname(SrcDir)]} || SrcDir <- get(src)], Root = {coverage, [{timestamp, Timestamp}, {'line-rate', LineRate}, {'lines-covered', LinesCovered}, {'lines-valid', LinesValid}, {'branch-rate', BranchRate}, {'branches-covered', BranchesCovered}, {'branches-valid', BranchesValid}, {complexity, Complexity}, {version, Version}], [{sources, Sources}, {packages, Result#result.data}]}, Report = xmerl:export_simple([Root], xmerl_xml, [{prolog, Prolog}]), write_output(Report, Output), ok. generate_packages(AppName, PrefixLen, Modules) -> PackageAndModules = lists:foldl(fun(Module, Acc) -> PackageName = package_name(AppName, PrefixLen, Module), case lists:keyfind(PackageName, 1, Acc) of false -> [{PackageName, [Module]} | Acc]; {_Key, ModulesInPackage} -> lists:keyreplace( PackageName, 1, Acc, {PackageName, [Module|ModulesInPackage]}) end end, [], Modules), Fun = fun({PackageName, ModulesInPackage}, Result) -> Package = generate_package(PackageName, ModulesInPackage), {Package#result.data, sum(Result, Package)} end, {Packages, Result} = lists:mapfoldl(Fun, #result{}, PackageAndModules), Result#result{data = Packages}. %% Package name is generated by %% - AppName itself %% - source directory %% - module prefix (name divided by "_") package_name(AppName, PrefixLen, Module) when is_atom(AppName), is_atom(Module) -> SourceDirs = case lookup_source(Module) of false -> []; SourceFile -> case filename:dirname(SourceFile) of "." -> []; DirName -> string:tokens(DirName, "/") end end, Prefix = case PrefixLen of 0 -> ""; _Other -> lists:sublist(string:tokens(atom_to_list(Module), "_"), PrefixLen) end, string:join([atom_to_list(AppName)] ++ SourceDirs ++ Prefix, "."). generate_package(PackageName, Modules) -> Classes = generate_classes(Modules), Data = {package, [{name, PackageName}, {'line-rate', rate(Classes#result.line)}, {'branch-rate', rate(Classes#result.branches)}, {complexity, 0}], [{classes, Classes#result.data}]}, #result{data = Data}. % generate element, each Erlang module is "class" generate_classes(Modules) -> % generate XML for every class, collect summary metric Fun = fun(Module, Result) -> Class = generate_class(Module), {Class#result.data, sum(Result, Class)} end, % Skip modules without sources Filter = fun(Module) -> case lookup_source(Module) of false -> false; _Other -> true end end, Modules2 = lists:filter(Filter, Modules), {Classes, Result} = lists:mapfoldl(Fun, #result{}, Modules2), Result#result{data = Classes}. generate_class(Module) -> Fun = fun({{_Module, Line}, Value}, Result) -> Covered = case Value of 0 -> 0; _Other -> 1 end, LineCoverage = sum(Result#result.line, {Covered, 1}), % add one line to the summary Data = {line, [{number, Line}, {hits, Value}], []}, {Data, Result#result{line = LineCoverage}} end, {ok, Lines} = cover:analyse(Module, calls, line), % XXX: ignore zero-indexed line, for some reason it is always present and always not hit Filter = fun({{_Module, 0}, 0}) -> false; (_Other) -> true end, Lines2 = lists:filter(Filter, Lines), {LinesData, Result} = lists:mapfoldl(Fun, #result{}, Lines2), Data = {class, [{name, Module}, {filename, lookup_source(Module)}, {'line-rate', rate(Result#result.line)}, {'branch-rate', rate(Result#result.branches)}, {complexity, 0}], [{methods, generate_methods(Module, LinesData)}, {lines, LinesData}]}, Result#result{data = Data}. generate_methods(Module, LinesData) -> ResultFun = fun ({line, LineData, _}, Result) -> Hits = proplists:get_value(hits, LineData, 0), Covered = case Hits of 0 -> 0; _Other -> 1 end, LineCoverage = sum(Result#result.line, {Covered, 1}), Result#result{line = LineCoverage} end, {ok, Functions} = cover:analyse(Module, calls, function), MFAs = lists:map(fun (F) -> element(1, F) end, Functions), lists:flatten(lists:map( fun ({_M, F, A} = MFA) -> case function_lines(MFA, LinesData) of [] -> []; FunLinesData -> Result = lists:foldl(ResultFun, #result{}, FunLinesData), {method, [{name, F}, {signature, io_lib:format("~s/~B", [F, A])}, {'line-rate', rate(Result#result.line)}, {'branch-rate', rate(Result#result.branches)}], [{lines, FunLinesData}]} end end, MFAs)). write_output(Report, Output) -> io:format("Writing output report '~s'...~n", [Output]), case file:open(Output, [write, {encoding, utf8}]) of {ok, Fd} -> ok = file:write(Fd, [Report, "\n"]), file:close(Fd); {error, Reason} -> io:format("Could not open '~s' due to ~p.~n", [Output, Reason]), halt(1) end, ok. % sum metrics sum(#result{line = {LineCovered1, LineValid1}, branches = {BranchesCovered1, BranchesValid1}}, #result{line = {LineCovered2, LineValid2}, branches = {BranchesCovered2, BranchesValid2}}) -> #result{line = {LineCovered1 + LineCovered2, LineValid1 + LineValid2}, branches = {BranchesCovered1 + BranchesCovered2, BranchesValid1 + BranchesValid2}}; sum({Covered1, Valid1}, {Covered2, Valid2}) -> {Covered1 + Covered2, Valid1 + Valid2}. rate({_Covered, 0}) -> "0.0"; rate({Covered, Valid}) -> [Res] = io_lib:format("~f", [Covered / Valid]), Res. % lookup source in source directory lookup_source(Module) -> Sources = get(src), Glob = "^" ++ atom_to_list(Module) ++ "\.erl\$", Name = lists:foldl( fun(SrcDir, false) -> Fun = fun (Name, _In) -> % substract directory case lists:prefix(SrcDir, Name) of true -> lists:nthtail(length(SrcDir), Name); false -> Name end end, filelib:fold_files(SrcDir, Glob, true, Fun, false); (_, Name) -> Name end, false, Sources), case Name of false -> false; [$/ | Relative] -> Relative; _Other -> Name end. % lookup start and end lines for function function_range({M, F, A}) -> function_range(get(ebin), M, F, A). function_range([EbinDir | RDirs], M, F, A) -> Beam = io_lib:format("~s/~s.beam", [EbinDir, M]), case beam_lib:chunks(Beam, [abstract_code]) of {error, beam_lib, _} -> function_range(RDirs, M, F, A); {ok, {M, [{abstract_code, no_abstract_code}]}} -> {0, 0}; {ok, {M, [{abstract_code, {_Version, AC}}]}} -> Filter = fun ({function, _, Fun, Ary, _}) -> not (Fun =:= F andalso Ary =:= A); (_) -> true end, case lists:dropwhile(Filter, AC) of [] -> {0, 0}; %% Should never happen unless beam is out of sync [{_, Line, F, A, _}] -> {Line, Line}; [{_, Line, F, A, _}, {eof, Next}] -> {Line, Next}; [{_, Line, F, A, _}, Following | _] -> {Line, element(2, Following) - 1} end end; function_range(_, _M, _F, _A) -> {0, 0}. % filter lines by function function_lines(MFA, LinesData) -> {Start, End} = function_range(MFA), lists:filter(fun (LineData) -> Line = proplists:get_value(number, element(2, LineData)), Line > Start andalso Line =< End end, LinesData).