Path: blob/main/src/resources/pandoc/datadir/luacov/runner.lua
12923 views
---------------------------------------------------1-- Statistics collecting module.2-- Calling the module table is a shortcut to calling the `init` function.3-- @class module4-- @name luacov.runner56local runner = {}7--- LuaCov version in `MAJOR.MINOR.PATCH` format.8runner.version = "0.15.0"910local stats = require("luacov.stats")11local util = require("luacov.util")12runner.defaults = require("luacov.defaults")1314local debug = require("debug")15local raw_os_exit = os.exit1617local new_anchor = newproxy or function() return {} end -- luacheck: compat1819-- Returns an anchor that runs fn when collected.20local function on_exit_wrap(fn)21local anchor = new_anchor()22debug.setmetatable(anchor, {__gc = fn})23return anchor24end2526runner.data = {}27runner.paused = true28runner.initialized = false29runner.tick = false3031-- Checks if a string matches at least one of patterns.32-- @param patterns array of patterns or nil33-- @param str string to match34-- @param on_empty return value in case of empty pattern array35local function match_any(patterns, str, on_empty)36if not patterns or not patterns[1] then37return on_empty38end3940for _, pattern in ipairs(patterns) do41if string.match(str, pattern) then42return true43end44end4546return false47end4849--------------------------------------------------50-- Uses LuaCov's configuration to check if a file is included for51-- coverage data collection.52-- @param filename name of the file.53-- @return true if file is included, false otherwise.54function runner.file_included(filename)55-- Normalize file names before using patterns.56filename = string.gsub(filename, "\\", "/")57filename = string.gsub(filename, "%.lua$", "")5859-- If include list is empty, everything is included by default.60-- If exclude list is empty, nothing is excluded by default.61return match_any(runner.configuration.include, filename, true) and62not match_any(runner.configuration.exclude, filename, false)63end6465--------------------------------------------------66-- Adds stats to an existing file stats table.67-- @param old_stats stats to be updated.68-- @param extra_stats another stats table, will be broken during update.69function runner.update_stats(old_stats, extra_stats)70old_stats.max = math.max(old_stats.max, extra_stats.max)7172-- Remove string keys so that they do not appear when iterating73-- over 'extra_stats'.74extra_stats.max = nil75extra_stats.max_hits = nil7677for line_nr, run_nr in pairs(extra_stats) do78old_stats[line_nr] = (old_stats[line_nr] or 0) + run_nr79old_stats.max_hits = math.max(old_stats.max_hits, old_stats[line_nr])80end81end8283-- Adds accumulated stats to existing stats file or writes a new one, then resets data.84function runner.save_stats()85local loaded = stats.load(runner.configuration.statsfile) or {}8687for name, file_data in pairs(runner.data) do88if loaded[name] then89runner.update_stats(loaded[name], file_data)90else91loaded[name] = file_data92end93end9495stats.save(runner.configuration.statsfile, loaded)96runner.data = {}97end9899local cluacov_ok = pcall(require, "cluacov.version")100101--------------------------------------------------102-- Debug hook set by LuaCov.103-- Acknowledges that a line is executed, but does nothing104-- if called manually before coverage gathering is started.105-- @param _ event type, should always be "line".106-- @param line_nr line number.107-- @param[opt] level passed to debug.getinfo to get name of processed file,108-- 2 by default. Increase it if this function is called manually109-- from another debug hook.110-- @usage111-- local function custom_hook(_, line)112-- runner.debug_hook(_, line, 3)113-- extra_processing(line)114-- end115-- @function debug_hook116runner.debug_hook = require(cluacov_ok and "cluacov.hook" or "luacov.hook").new(runner)117118------------------------------------------------------119-- Runs the reporter specified in configuration.120-- @param[opt] configuration if string, filename of config file (used to call `load_config`).121-- If table then config table (see file `luacov.default.lua` for an example).122-- If `configuration.reporter` is not set, runs the default reporter;123-- otherwise, it must be a module name in 'luacov.reporter' namespace.124-- The module must contain 'report' function, which is called without arguments.125function runner.run_report(configuration)126configuration = runner.load_config(configuration)127local reporter = "luacov.reporter"128129if configuration.reporter then130reporter = reporter .. "." .. configuration.reporter131end132133require(reporter).report()134end135136local on_exit_run_once = false137138local function on_exit()139-- Lua >= 5.2 could call __gc when user call os.exit140-- so this method could be called twice141if on_exit_run_once then return end142on_exit_run_once = true143-- disable hooks before aggregating stats144debug.sethook(nil)145runner.save_stats()146147if runner.configuration.runreport then148runner.run_report(runner.configuration)149end150end151152local dir_sep = package.config:sub(1, 1)153local wildcard_expansion = "[^/]+"154155if not dir_sep:find("[/\\]") then156dir_sep = "/"157end158159local function escape_module_punctuation(ch)160if ch == "." then161return "/"162elseif ch == "*" then163return wildcard_expansion164else165return "%" .. ch166end167end168169local function reversed_module_name_parts(name)170local parts = {}171172for part in name:gmatch("[^%.]+") do173table.insert(parts, 1, part)174end175176return parts177end178179-- This function is used for sorting module names.180-- More specific names should come first.181-- E.g. rule for 'foo.bar' should override rule for 'foo.*',182-- rule for 'foo.*' should override rule for 'foo.*.*',183-- and rule for 'a.b' should override rule for 'b'.184-- To be more precise, because names become patterns that are matched185-- from the end, the name that has the first (from the end) literal part186-- (and the corresponding part for the other name is not literal)187-- is considered more specific.188local function compare_names(name1, name2)189local parts1 = reversed_module_name_parts(name1)190local parts2 = reversed_module_name_parts(name2)191192for i = 1, math.max(#parts1, #parts2) do193if not parts1[i] then return false end194if not parts2[i] then return true end195196local is_literal1 = not parts1[i]:find("%*")197local is_literal2 = not parts2[i]:find("%*")198199if is_literal1 ~= is_literal2 then200return is_literal1201end202end203204-- Names are at the same level of specificness,205-- fall back to lexicographical comparison.206return name1 < name2207end208209-- Sets runner.modules using runner.configuration.modules.210-- Produces arrays of module patterns and filenames and sets211-- them as runner.modules.patterns and runner.modules.filenames.212-- Appends these patterns to the include list.213local function acknowledge_modules()214runner.modules = {patterns = {}, filenames = {}}215216if not runner.configuration.modules then217return218end219220if not runner.configuration.include then221runner.configuration.include = {}222end223224local names = {}225226for name in pairs(runner.configuration.modules) do227table.insert(names, name)228end229230table.sort(names, compare_names)231232for _, name in ipairs(names) do233local pattern = name:gsub("%p", escape_module_punctuation) .. "$"234local filename = runner.configuration.modules[name]:gsub("[/\\]", dir_sep)235table.insert(runner.modules.patterns, pattern)236table.insert(runner.configuration.include, pattern)237table.insert(runner.modules.filenames, filename)238239if filename:match("init%.lua$") then240pattern = pattern:gsub("$$", "/init$")241table.insert(runner.modules.patterns, pattern)242table.insert(runner.configuration.include, pattern)243table.insert(runner.modules.filenames, filename)244end245end246end247248--------------------------------------------------249-- Returns real name for a source file name250-- using `luacov.defaults.modules` option.251-- @param filename name of the file.252function runner.real_name(filename)253local orig_filename = filename254-- Normalize file names before using patterns.255filename = filename:gsub("\\", "/"):gsub("%.lua$", "")256257for i, pattern in ipairs(runner.modules.patterns) do258local match = filename:match(pattern)259260if match then261local new_filename = runner.modules.filenames[i]262263if pattern:find(wildcard_expansion, 1, true) then264-- Given a prefix directory, join it265-- with matched part of source file name.266if not new_filename:match("/$") then267new_filename = new_filename .. "/"268end269270new_filename = new_filename .. match .. ".lua"271end272273-- Switch slashes back to native.274return (new_filename:gsub("^%.[/\\]", ""):gsub("[/\\]", dir_sep))275end276end277278return orig_filename279end280281-- Always exclude luacov's own files.282local luacov_excludes = {283"luacov$",284"luacov/hook$",285"luacov/reporter$",286"luacov/reporter/default$",287"luacov/defaults$",288"luacov/runner$",289"luacov/stats$",290"luacov/tick$",291"luacov/util$",292"cluacov/version$"293}294295local function is_absolute(path)296if path:sub(1, 1) == dir_sep or path:sub(1, 1) == "/" then297return true298end299300if dir_sep == "\\" and path:find("^%a:") then301return true302end303304return false305end306307local function get_cur_dir()308local pwd_cmd = dir_sep == "\\" and "cd 2>nul" or "pwd 2>/dev/null"309local handler = io.popen(pwd_cmd, "r")310local cur_dir = handler:read()311handler:close()312cur_dir = cur_dir:gsub("\r?\n$", "")313314if cur_dir:sub(-1) ~= dir_sep and cur_dir:sub(-1) ~= "/" then315cur_dir = cur_dir .. dir_sep316end317318return cur_dir319end320321-- Sets configuration. If some options are missing, default values are used instead.322local function set_config(configuration)323runner.configuration = {}324325for option, default_value in pairs(runner.defaults) do326runner.configuration[option] = default_value327end328329for option, value in pairs(configuration) do330runner.configuration[option] = value331end332333-- Program using LuaCov may change directory during its execution.334-- Convert path options to absolute paths to use correct paths anyway.335local cur_dir336337for _, option in ipairs({"statsfile", "reportfile"}) do338local path = runner.configuration[option]339340if not is_absolute(path) then341cur_dir = cur_dir or get_cur_dir()342runner.configuration[option] = cur_dir .. path343end344end345346acknowledge_modules()347348for _, patt in ipairs(luacov_excludes) do349table.insert(runner.configuration.exclude, patt)350end351352runner.tick = runner.tick or runner.configuration.tick353end354355local function load_config_file(name, is_default)356local conf = setmetatable({}, {__index = _G})357358local ok, ret, error_msg = util.load_config(name, conf)359360if ok then361if type(ret) == "table" then362for key, value in pairs(ret) do363if conf[key] == nil then364conf[key] = value365end366end367end368369return conf370end371372local error_type = ret373374if error_type == "read" and is_default then375return nil376end377378io.stderr:write(("Error: couldn't %s config file %s: %s\n"):format(error_type, name, error_msg))379raw_os_exit(1)380end381382local default_config_file = ".luacov"383384------------------------------------------------------385-- Loads a valid configuration.386-- @param[opt] configuration user provided config (config-table or filename)387-- @return existing configuration if already set, otherwise loads a new388-- config from the provided data or the defaults.389-- When loading a new config, if some options are missing, default values390-- from `luacov.defaults` are used instead.391function runner.load_config(configuration)392if not runner.configuration then393if not configuration then394-- Nothing provided, load from default location if possible.395set_config(load_config_file(default_config_file, true) or runner.defaults)396elseif type(configuration) == "string" then397set_config(load_config_file(configuration))398elseif type(configuration) == "table" then399set_config(configuration)400else401error("Expected filename, config table or nil. Got " .. type(configuration))402end403end404405return runner.configuration406end407408--------------------------------------------------409-- Pauses saving data collected by LuaCov's runner.410-- Allows other processes to write to the same stats file.411-- Data is still collected during pause.412function runner.pause()413runner.paused = true414end415416--------------------------------------------------417-- Resumes saving data collected by LuaCov's runner.418function runner.resume()419runner.paused = false420end421422local hook_per_thread423424-- Determines whether debug hooks are separate for each thread.425local function has_hook_per_thread()426if hook_per_thread == nil then427local old_hook, old_mask, old_count = debug.gethook()428local noop = function() end429debug.sethook(noop, "l")430local thread_hook = coroutine.wrap(function() return debug.gethook() end)()431hook_per_thread = thread_hook ~= noop432debug.sethook(old_hook, old_mask, old_count)433end434435return hook_per_thread436end437438--------------------------------------------------439-- Wraps a function, enabling coverage gathering in it explicitly.440-- LuaCov gathers coverage using a debug hook, and patches coroutine441-- library to set it on created threads when under standard Lua, where each442-- coroutine has its own hook. If a coroutine is created using Lua C API443-- or before the monkey-patching, this wrapper should be applied to the444-- main function of the coroutine. Under LuaJIT this function is redundant,445-- as there is only one, global debug hook.446-- @param f a function447-- @return a function that enables coverage gathering and calls the original function.448-- @usage449-- local coro = coroutine.create(runner.with_luacov(func))450function runner.with_luacov(f)451return function(...)452if has_hook_per_thread() then453debug.sethook(runner.debug_hook, "l")454end455456return f(...)457end458end459460--------------------------------------------------461-- Initializes LuaCov runner to start collecting data.462-- @param[opt] configuration if string, filename of config file (used to call `load_config`).463-- If table then config table (see file `luacov.default.lua` for an example)464function runner.init(configuration)465runner.configuration = runner.load_config(configuration)466467-- metatable trick on filehandle won't work if Lua exits through468-- os.exit() hence wrap that with exit code as well469os.exit = function(...) -- luacheck: no global470on_exit()471raw_os_exit(...)472end473474debug.sethook(runner.debug_hook, "l")475476if has_hook_per_thread() then477-- debug must be set for each coroutine separately478-- hence wrap coroutine function to set the hook there479-- as well480local rawcoroutinecreate = coroutine.create481coroutine.create = function(...) -- luacheck: no global482local co = rawcoroutinecreate(...)483debug.sethook(co, runner.debug_hook, "l")484return co485end486487-- Version of assert which handles non-string errors properly.488local function safeassert(ok, ...)489if ok then490return ...491else492error(..., 0)493end494end495496coroutine.wrap = function(...) -- luacheck: no global497local co = rawcoroutinecreate(...)498debug.sethook(co, runner.debug_hook, "l")499return function(...)500return safeassert(coroutine.resume(co, ...))501end502end503end504505if not runner.tick then506runner.on_exit_trick = on_exit_wrap(on_exit)507end508509runner.initialized = true510runner.paused = false511end512513--------------------------------------------------514-- Shuts down LuaCov's runner.515-- This should only be called from daemon processes or sandboxes which have516-- disabled os.exit and other hooks that are used to determine shutdown.517function runner.shutdown()518on_exit()519end520521-- Gets the sourcefilename from a function.522-- @param func function to lookup.523-- @return sourcefilename or nil when not found.524local function getsourcefile(func)525assert(type(func) == "function")526local d = debug.getinfo(func).source527if d and d:sub(1, 1) == "@" then528return d:sub(2)529end530end531532-- Looks for a function inside a table.533-- @param searched set of already checked tables.534local function findfunction(t, searched)535if searched[t] then536return537end538539searched[t] = true540541for _, v in pairs(t) do542if type(v) == "function" then543return v544elseif type(v) == "table" then545local func = findfunction(v, searched)546if func then return func end547end548end549end550551-- Gets source filename from a file name, module name, function or table.552-- @param name string; filename,553-- string; modulename as passed to require(),554-- function; where containing file is looked up,555-- table; module table where containing file is looked up556-- @raise error message if could not find source filename.557-- @return source filename.558local function getfilename(name)559if type(name) == "function" then560local sourcefile = getsourcefile(name)561562if not sourcefile then563error("Could not infer source filename")564end565566return sourcefile567elseif type(name) == "table" then568local func = findfunction(name, {})569570if not func then571error("Could not find a function within " .. tostring(name))572end573574return getfilename(func)575else576if type(name) ~= "string" then577error("Bad argument: " .. tostring(name))578end579580if util.file_exists(name) then581return name582end583584local success, result = pcall(require, name)585586if not success then587error("Module/file '" .. name .. "' was not found")588end589590if type(result) ~= "table" and type(result) ~= "function" then591error("Module '" .. name .. "' did not return a result to lookup its file name")592end593594return getfilename(result)595end596end597598-- Escapes a filename.599-- Escapes magic pattern characters, removes .lua extension600-- and replaces dir seps by '/'.601local function escapefilename(name)602return name:gsub("%.lua$", ""):gsub("[%%%^%$%.%(%)%[%]%+%*%-%?]","%%%0"):gsub("\\", "/")603end604605local function addfiletolist(name, list)606local f = "^"..escapefilename(getfilename(name)).."$"607table.insert(list, f)608return f609end610611local function addtreetolist(name, level, list)612local f = escapefilename(getfilename(name))613614if level or f:match("/init$") then615-- chop the last backslash and everything after it616f = f:match("^(.*)/") or f617end618619local t = "^"..f.."/" -- the tree behind the file620f = "^"..f.."$" -- the file621table.insert(list, f)622table.insert(list, t)623return f, t624end625626-- Returns a pcall result, with the initial 'true' value removed627-- and 'false' replaced with nil.628local function checkresult(ok, ...)629if ok then630return ... -- success, strip 'true' value631else632return nil, ... -- failure; nil + error633end634end635636-------------------------------------------------------------------637-- Adds a file to the exclude list (see `luacov.defaults`).638-- If passed a function, then through debuginfo the source filename is collected. In case of a table639-- it will recursively search the table for a function, which is then resolved to a filename through debuginfo.640-- If the parameter is a string, it will first check if a file by that name exists. If it doesn't exist641-- it will call `require(name)` to load a module by that name, and the result of require (function or642-- table expected) is used as described above to get the sourcefile.643-- @param name644-- * string; literal filename,645-- * string; modulename as passed to require(),646-- * function; where containing file is looked up,647-- * table; module table where containing file is looked up648-- @return the pattern as added to the list, or nil + error649function runner.excludefile(name)650return checkresult(pcall(addfiletolist, name, runner.configuration.exclude))651end652-------------------------------------------------------------------653-- Adds a file to the include list (see `luacov.defaults`).654-- @param name see `excludefile`655-- @return the pattern as added to the list, or nil + error656function runner.includefile(name)657return checkresult(pcall(addfiletolist, name, runner.configuration.include))658end659-------------------------------------------------------------------660-- Adds a tree to the exclude list (see `luacov.defaults`).661-- If `name = 'luacov'` and `level = nil` then662-- module 'luacov' (luacov.lua) and the tree 'luacov' (containing `luacov/runner.lua` etc.) is excluded.663-- If `name = 'pl.path'` and `level = true` then664-- module 'pl' (pl.lua) and the tree 'pl' (containing `pl/path.lua` etc.) is excluded.665-- NOTE: in case of an 'init.lua' file, the 'level' parameter will always be set666-- @param name see `excludefile`667-- @param level if truthy then one level up is added, including the tree668-- @return the 2 patterns as added to the list (file and tree), or nil + error669function runner.excludetree(name, level)670return checkresult(pcall(addtreetolist, name, level, runner.configuration.exclude))671end672-------------------------------------------------------------------673-- Adds a tree to the include list (see `luacov.defaults`).674-- @param name see `excludefile`675-- @param level see `includetree`676-- @return the 2 patterns as added to the list (file and tree), or nil + error677function runner.includetree(name, level)678return checkresult(pcall(addtreetolist, name, level, runner.configuration.include))679end680681682return setmetatable(runner, {__call = function(_, configfile) runner.init(configfile) end})683684685