Path: blob/main/src/resources/pandoc/datadir/luacov/reporter.lua
12923 views
------------------------1-- Report module, will transform statistics file into a report.2-- @class module3-- @name luacov.reporter4local reporter = {}56local LineScanner = require("luacov.linescanner")7local luacov = require("luacov.runner")8local util = require("luacov.util")9local lfs_ok, lfs = pcall(require, "lfs")1011----------------------------------------------------------------12local dir_sep = package.config:sub(1, 1)13if not dir_sep:find("[/\\]") then14dir_sep = "/"15end161718--- returns all files inside dir19--- @param dir directory to be listed20--- @treturn table with filenames and attributes21local function dirtree(dir)22assert(dir and dir ~= "", "Please pass directory parameter")23if dir:sub(-1):match("[/\\]") then24dir=string.sub(dir, 1, -2)25end2627dir = dir:gsub("[/\\]", dir_sep)2829local function yieldtree(directory)30for entry in lfs.dir(directory) do31if entry ~= "." and entry ~= ".." then32entry=directory..dir_sep..entry33local attr=lfs.attributes(entry)34coroutine.yield(entry,attr)35if attr.mode == "directory" then36yieldtree(entry)37end38end39end40end4142return coroutine.wrap(function() yieldtree(dir) end)43end4445----------------------------------------------------------------46--- checks if string 'filename' has pattern 'pattern'47--- @param filename48--- @param pattern49--- @return boolean50local function fileMatches(filename, pattern)51return string.find(filename, pattern)52end5354----------------------------------------------------------------55--- Basic reporter class stub.56-- Implements 'new', 'run' and 'close' methods required by `report`.57-- Provides some helper methods and stubs to be overridden by child classes.58-- @usage59-- local MyReporter = setmetatable({}, ReporterBase)60-- MyReporter.__index = MyReporter61-- function MyReporter:on_hit_line(...)62-- self:write(("File %s: hit line %s %d times"):format(...))63-- end64-- @type ReporterBase65local ReporterBase = {} do66ReporterBase.__index = ReporterBase6768function ReporterBase:new(conf)69local stats = require("luacov.stats")70local data = stats.load(conf.statsfile)7172if not data then73return nil, "Could not load stats file " .. conf.statsfile .. "."74end7576local files = {}77local filtered_data = {}78local max_hits = 07980-- Several original paths can map to one real path,81-- their stats should be merged in this case.82for filename, file_stats in pairs(data) do83if luacov.file_included(filename) then84filename = luacov.real_name(filename)8586if filtered_data[filename] then87luacov.update_stats(filtered_data[filename], file_stats)88else89table.insert(files, filename)90filtered_data[filename] = file_stats91end9293max_hits = math.max(max_hits, filtered_data[filename].max_hits)94end95end9697-- including files without tests98-- only .lua files99if conf.includeuntestedfiles then100if not lfs_ok then101print("The option includeuntestedfiles requires the lfs module (from luafilesystem) to be installed.")102os.exit(1)103end104105local function add_empty_file_coverage_data(file_path)106107-- Leading "./" must be trimmed from the file paths because the paths of tested108-- files do not have a leading "./" either109if (file_path:match("^%.[/\\]")) then110file_path = file_path:sub(3)111end112113if luacov.file_included(file_path) then114local file_stats = {115max = 0,116max_hits = 0117}118119local filename = luacov.real_name(file_path)120121if not filtered_data[filename] then122table.insert(files, filename)123filtered_data[filename] = file_stats124end125end126127end128129local function add_empty_dir_coverage_data(directory_path)130131for filename, attr in dirtree(directory_path) do132if attr.mode == "file" and fileMatches(filename, '.%.lua$') then133add_empty_file_coverage_data(filename)134end135end136137end138139if (conf.includeuntestedfiles == true) then140add_empty_dir_coverage_data("." .. dir_sep)141142elseif (type(conf.includeuntestedfiles) == "table" and conf.includeuntestedfiles[1]) then143for _, include_path in ipairs(conf.includeuntestedfiles) do144if (fileMatches(include_path, '.%.lua$')) then145add_empty_file_coverage_data(include_path)146else147add_empty_dir_coverage_data(include_path)148end149end150end151152end153154table.sort(files)155156local out, err = io.open(conf.reportfile, "w")157if not out then return nil, err end158159local o = setmetatable({160_out = out,161_cfg = conf,162_data = filtered_data,163_files = files,164_mhit = max_hits,165}, self)166167return o168end169170--- Returns configuration table.171-- @see luacov.defaults172function ReporterBase:config()173return self._cfg174end175176--- Returns maximum number of hits per line in all coverage data.177function ReporterBase:max_hits()178return self._mhit179end180181--- Writes strings to report file.182-- @param ... strings.183function ReporterBase:write(...)184return self._out:write(...)185end186187function ReporterBase:close()188self._out:close()189self._private = nil190end191192--- Returns array of filenames to be reported.193function ReporterBase:files()194return self._files195end196197--- Returns coverage data for a file.198-- @param filename name of the file.199-- @see luacov.stats.load200function ReporterBase:stats(filename)201return self._data[filename]202end203204-- Stub methods follow.205-- luacheck: push no unused args206207--- Stub method called before reporting.208function ReporterBase:on_start()209end210211--- Stub method called before processing a file.212-- @param filename name of the file.213function ReporterBase:on_new_file(filename)214end215216--- Stub method called if a file couldn't be processed due to an error.217-- @param filename name of the file.218-- @param error_type "open", "read" or "load".219-- @param message error message.220function ReporterBase:on_file_error(filename, error_type, message)221end222223--- Stub method called for each empty source line224-- and other lines that can't be hit.225-- @param filename name of the file.226-- @param lineno line number.227-- @param line the line itself as a string.228function ReporterBase:on_empty_line(filename, lineno, line)229end230231--- Stub method called for each missed source line.232-- @param filename name of the file.233-- @param lineno line number.234-- @param line the line itself as a string.235function ReporterBase:on_mis_line(filename, lineno, line)236end237238--- Stub method called for each hit source line.239-- @param filename name of the file.240-- @param lineno line number.241-- @param line the line itself as a string.242-- @param hits number of times the line was hit. Should be positive.243function ReporterBase:on_hit_line(filename, lineno, line, hits)244end245246--- Stub method called after a file has been processed.247-- @param filename name of the file.248-- @param hits total number of hit lines in the file.249-- @param miss total number of missed lines in the file.250function ReporterBase:on_end_file(filename, hits, miss)251end252253--- Stub method called after reporting.254function ReporterBase:on_end()255end256257-- luacheck: pop258259local cluacov_ok = pcall(require, "cluacov.version")260local deepactivelines261262if cluacov_ok then263deepactivelines = require("cluacov.deepactivelines")264end265266function ReporterBase:_run_file(filename)267local file, open_err = io.open(filename)268269if not file then270self:on_file_error(filename, "open", util.unprefix(open_err, filename .. ": "))271return272end273274local active_lines275276if cluacov_ok then277local src, read_err = file:read("*a")278279if not src then280self:on_file_error(filename, "read", read_err)281return282end283284src = src:gsub("^#![^\n]*", "")285local func, load_err = util.load_string(src, nil, "@file")286287if not func then288self:on_file_error(filename, "load", "line " .. util.unprefix(load_err, "file:"))289return290end291292active_lines = deepactivelines.get(func)293file:seek("set")294end295296self:on_new_file(filename)297local file_hits, file_miss = 0, 0298local filedata = self:stats(filename)299300local line_nr = 1301local scanner = LineScanner:new()302303while true do304local line = file:read("*l")305if not line then break end306307local always_excluded, excluded_when_not_hit = scanner:consume(line)308local hits = filedata[line_nr] or 0309local included = not always_excluded and (not excluded_when_not_hit or hits ~= 0)310311if cluacov_ok then312included = included and active_lines[line_nr]313end314315if included then316if hits == 0 then317self:on_mis_line(filename, line_nr, line)318file_miss = file_miss + 1319else320self:on_hit_line(filename, line_nr, line, hits)321file_hits = file_hits + 1322end323else324self:on_empty_line(filename, line_nr, line)325end326327line_nr = line_nr + 1328end329330file:close()331self:on_end_file(filename, file_hits, file_miss)332end333334function ReporterBase:run()335self:on_start()336337for _, filename in ipairs(self:files()) do338self:_run_file(filename)339end340341self:on_end()342end343344end345--- @section end346----------------------------------------------------------------347348----------------------------------------------------------------349local DefaultReporter = setmetatable({}, ReporterBase) do350DefaultReporter.__index = DefaultReporter351352function DefaultReporter:on_start()353local most_hits = self:max_hits()354local most_hits_length = #("%d"):format(most_hits)355356self._summary = {}357self._empty_format = (" "):rep(most_hits_length + 1)358self._zero_format = ("*"):rep(most_hits_length).."0"359self._count_format = ("%% %dd"):format(most_hits_length+1)360self._printed_first_header = false361end362363function DefaultReporter:on_new_file(filename)364self:write(("="):rep(78), "\n")365self:write(filename, "\n")366self:write(("="):rep(78), "\n")367end368369function DefaultReporter:on_file_error(filename, error_type, message) --luacheck: no self370io.stderr:write(("Couldn't %s %s: %s\n"):format(error_type, filename, message))371end372373function DefaultReporter:on_empty_line(_, _, line)374if line == "" then375self:write("\n")376else377self:write(self._empty_format, " ", line, "\n")378end379end380381function DefaultReporter:on_mis_line(_, _, line)382self:write(self._zero_format, " ", line, "\n")383end384385function DefaultReporter:on_hit_line(_, _, line, hits)386self:write(self._count_format:format(hits), " ", line, "\n")387end388389function DefaultReporter:on_end_file(filename, hits, miss)390self._summary[filename] = { hits = hits, miss = miss }391self:write("\n")392end393394local function coverage_to_string(hits, missed)395local total = hits + missed396397if total == 0 then398total = 1399end400401return ("%.2f%%"):format(hits/total*100.0)402end403404function DefaultReporter:on_end()405self:write(("="):rep(78), "\n")406self:write("Summary\n")407self:write(("="):rep(78), "\n")408self:write("\n")409410local lines = {{"File", "Hits", "Missed", "Coverage"}}411local total_hits, total_missed = 0, 0412413for _, filename in ipairs(self:files()) do414local summary = self._summary[filename]415416if summary then417local hits, missed = summary.hits, summary.miss418419table.insert(lines, {420filename,421tostring(summary.hits),422tostring(summary.miss),423coverage_to_string(hits, missed)424})425426total_hits = total_hits + hits427total_missed = total_missed + missed428end429end430431table.insert(lines, {432"Total",433tostring(total_hits),434tostring(total_missed),435coverage_to_string(total_hits, total_missed)436})437438local max_column_lengths = {}439440for _, line in ipairs(lines) do441for column_nr, column in ipairs(line) do442max_column_lengths[column_nr] = math.max(max_column_lengths[column_nr] or -1, #column)443end444end445446local table_width = #max_column_lengths - 1447448for _, column_length in ipairs(max_column_lengths) do449table_width = table_width + column_length450end451452453for line_nr, line in ipairs(lines) do454if line_nr == #lines or line_nr == 2 then455self:write(("-"):rep(table_width), "\n")456end457458for column_nr, column in ipairs(line) do459self:write(column)460461if column_nr == #line then462self:write("\n")463else464self:write((" "):rep(max_column_lengths[column_nr] - #column + 1))465end466end467end468end469470end471----------------------------------------------------------------472473--- Runs the report generator.474-- To load a config, use `luacov.runner.load_config` first.475-- @param[opt] reporter_class custom reporter class. Will be476-- instantiated using 'new' method with configuration477-- (see `luacov.defaults`) as the argument. It should478-- return nil + error if something went wrong.479-- After acquiring a reporter object its 'run' and 'close'480-- methods will be called.481-- The easiest way to implement a custom reporter class is to482-- extend `ReporterBase`.483function reporter.report(reporter_class)484local configuration = luacov.load_config()485486reporter_class = reporter_class or DefaultReporter487488local rep, err = reporter_class:new(configuration)489490if not rep then491print(err)492print("Run your Lua program with -lluacov and then rerun luacov.")493os.exit(1)494end495496rep:run()497498rep:close()499500if configuration.deletestats then501os.remove(configuration.statsfile)502end503end504505reporter.ReporterBase = ReporterBase506507reporter.DefaultReporter = DefaultReporter508509return reporter510511512