local cache = require "luacheck.cache"
|
local config = require "luacheck.config"
|
local expand_rockspec = require "luacheck.expand_rockspec"
|
local format = require "luacheck.format"
|
local fs = require "luacheck.fs"
|
local globbing = require "luacheck.globbing"
|
local luacheck = require "luacheck"
|
local multithreading = require "luacheck.multithreading"
|
local options = require "luacheck.options"
|
local utils = require "luacheck.utils"
|
|
local runner = {}
|
|
local Runner = utils.class()
|
|
function Runner:__init(config_stack)
|
self._config_stack = config_stack
|
end
|
|
local config_options = {
|
config = utils.has_type_or_false("string"),
|
default_config = utils.has_type_or_false("string")
|
}
|
|
function runner.new(opts)
|
local ok, err = options.validate(config_options, opts)
|
|
if not ok then
|
error(("bad argument #1 to 'runner.new' (%s)"):format(err))
|
end
|
|
local base_config, config_err = config.load_config(opts.config, opts.default_config)
|
|
if not base_config then
|
return nil, config_err
|
end
|
|
local override_config = config.table_to_config(opts)
|
|
local config_stack
|
config_stack, err = config.stack_configs({base_config, override_config})
|
|
if not config_stack then
|
return nil, err
|
end
|
|
return Runner(config_stack)
|
end
|
|
local function validate_inputs(inputs)
|
if type(inputs) ~= "table" then
|
return nil, ("inputs table expected, got %s"):format(inputs)
|
end
|
|
for index, input in ipairs(inputs) do
|
local context = ("invalid input table at index [%d]"):format(index)
|
|
if type(input) ~= "table" then
|
return nil, ("%s: table expected, got %s"):format(context, type(input))
|
end
|
|
local specifies_source
|
|
for _, field in ipairs({"file", "filename", "path", "rockspec_path", "string"}) do
|
if input[field] ~= nil then
|
if field == "file" then
|
if io.type(input[field]) ~= "file" then
|
return nil, ("%s: invalid field 'file': open file expected, got %s"):format(
|
context, type(input[field]))
|
end
|
elseif type(input[field]) ~= "string" then
|
return nil, ("%s: invalid field '%s': string expected, got %s"):format(
|
context, field, type(input[field]))
|
end
|
|
if field ~= "filename" then
|
specifies_source = true
|
end
|
end
|
end
|
|
if not specifies_source then
|
return nil, ("%s: one of fields 'path', 'rockspec_path', 'file', or 'string' must be present"):format(context)
|
end
|
end
|
|
return true
|
end
|
|
local function matches_any(globs, filename)
|
for _, glob in ipairs(globs) do
|
if globbing.match(glob, filename) then
|
return true
|
end
|
end
|
|
return false
|
end
|
|
function Runner:_is_filename_included(abs_filename)
|
return not matches_any(self._top_opts.exclude_files, abs_filename) and (
|
#self._top_opts.include_files == 0 or matches_any(self._top_opts.include_files, abs_filename))
|
end
|
|
-- Normalizes inputs and filters inputs using `exclude_files` and `include_files` options.
|
-- Returns an array of prepared input tables.
|
-- Differences between normal and prepated inputs:
|
-- * Prepared inputs can't have `rockspec_path` field.
|
-- * Prepared inputs can't have `path` pointing to a directory (unless it has an error).
|
-- * Prepared inputs have `filename` field if possible (copied from `path` if not given).
|
-- * Prepared inputs that have `path` field also have `abs_path` field.
|
-- * Prepared inputs can have `fatal` field if the input can't be checked. The value is error type as a string.
|
-- `fatal` is always accompanied by an error message in `msg` field.
|
function Runner:_prepare_inputs(inputs)
|
local current_dir = fs.get_current_dir()
|
local dir_pattern = #self._top_opts.include_files > 0 and "" or "%.lua$"
|
|
local res = {}
|
|
local function add(input)
|
if input.path then
|
-- TODO: get rid of this, adjust fs.extract_files to avoid leading `./` instead.
|
input.path = input.path:gsub("^%.[/\\]([^/])", "%1")
|
input.abs_path = fs.normalize(fs.join(current_dir, input.path))
|
end
|
|
local abs_filename
|
|
if input.filename then
|
abs_filename = fs.normalize(fs.join(current_dir, input.filename))
|
else
|
input.filename = input.path
|
abs_filename = input.abs_path
|
end
|
|
if not input.filename or self:_is_filename_included(abs_filename) then
|
table.insert(res, input)
|
end
|
end
|
|
for _, input in ipairs(inputs) do
|
if input.path then
|
if fs.is_dir(input.path) then
|
local filenames, err_map = fs.extract_files(input.path, dir_pattern)
|
|
for _, filename in ipairs(filenames) do
|
local err = err_map[filename]
|
if err then
|
add({path = filename, fatal = "I/O", msg = err, filename = input.filename})
|
else
|
add({path = filename, filename = input.filename})
|
end
|
end
|
else
|
add({path = input.path, filename = input.filename})
|
end
|
elseif input.rockspec_path then
|
local filenames, fatal, err = expand_rockspec(input.rockspec_path)
|
|
if filenames then
|
for _, filename in ipairs(filenames) do
|
add({path = filename, filename = input.filename})
|
end
|
else
|
add({path = input.rockspec_path, fatal = fatal, msg = err, filename = input.filename})
|
end
|
elseif input.file then
|
add({file = input.file, filename = input.filename})
|
elseif input.string then
|
add({string = input.string, filename = input.filename})
|
else
|
-- Validation should ensure this never happens.
|
error("input doesn't specify source to check")
|
end
|
end
|
|
return res
|
end
|
|
-- Adds `mtime` field to inputs eligible for caching.
|
-- On failure no field is added, most likely the file doesn't exist
|
-- or is unreadable and it's better to get the error when trying to read it.
|
local function add_mtimes(inputs)
|
for _, input in ipairs(inputs) do
|
if input.path and not input.fatal then
|
input.mtime = fs.get_mtime(input.path)
|
end
|
end
|
end
|
|
-- Loads cached reports for input with `mtime` field, assigns them to `cached_report` field.
|
-- Returns true on success or nil and an error message on failure.
|
function Runner:_add_cached_reports(inputs)
|
local potentially_cached_filenames = {}
|
local mtimes = {}
|
|
for _, input in ipairs(inputs) do
|
if input.mtime then
|
table.insert(potentially_cached_filenames, input.abs_path)
|
table.insert(mtimes, input.mtime)
|
end
|
end
|
|
local filename_to_cached_report = cache.load(self._top_opts.cache, potentially_cached_filenames, mtimes)
|
|
if not filename_to_cached_report then
|
return nil, ("Couldn't load cache from %s: data corrupted"):format(self._top_opts.cache)
|
end
|
|
for _, input in ipairs(inputs) do
|
input.cached_report = filename_to_cached_report[input.abs_path]
|
end
|
|
return true
|
end
|
|
-- Adds report as `new_report` field to all inputs that don't have a fatal error or a cached report.
|
-- Adds `fatal` and `msg` instead if there was an I/O error.
|
function Runner:_add_new_reports(inputs)
|
local sources = {}
|
local original_indexes = {}
|
|
for index, input in ipairs(inputs) do
|
if not input.fatal and not input.cached_report then
|
if input.string then
|
table.insert(sources, input.string)
|
table.insert(original_indexes, index)
|
else
|
local source, err = utils.read_file(input.path or input.file)
|
|
if source then
|
table.insert(sources, source)
|
table.insert(original_indexes, index)
|
else
|
input.fatal = "I/O"
|
input.msg = err
|
end
|
end
|
end
|
end
|
|
local map = multithreading.has_lanes and multithreading.pmap or utils.map
|
local reports = map(luacheck.get_report, sources, self._top_opts.jobs)
|
|
for index, report in ipairs(reports) do
|
inputs[original_indexes[index]].new_report = report
|
end
|
end
|
|
-- Saves `new_report` for files eligible for caching to cache.
|
-- Returns true on success or nil and an error message on failure.
|
function Runner:_save_new_reports_to_cache(inputs)
|
local filenames = {}
|
local mtimes = {}
|
local reports = {}
|
|
for _, input in ipairs(inputs) do
|
if input.new_report and input.path then
|
-- If report for a file could be cached but getting its `mtime` has failed,
|
-- ignore the error - report is already here, might as well return it.
|
if input.mtime then
|
table.insert(filenames, input.abs_path)
|
table.insert(mtimes, input.mtime)
|
table.insert(reports, input.new_report)
|
end
|
end
|
end
|
|
local ok = cache.update(self._top_opts.cache, filenames, mtimes, reports)
|
|
if ok then
|
return true
|
else
|
return nil, ("Couldn't save cache to %s: I/O error"):format(self._top_opts.cache)
|
end
|
end
|
|
-- Inputs are prepared here, see `Runner:_prepare_inputs`.
|
-- Returns an array of reports, one per input, possibly annotated with fields `fatal`, `msg`, and `filename`.
|
-- On critical error returns nil and an error message.
|
function Runner:_get_reports(inputs)
|
if self._top_opts.cache then
|
add_mtimes(inputs)
|
local ok, err = self:_add_cached_reports(inputs)
|
|
if not ok then
|
return nil, err
|
end
|
end
|
|
self:_add_new_reports(inputs)
|
|
if self._top_opts.cache then
|
local ok, err = self:_save_new_reports_to_cache(inputs)
|
|
if not ok then
|
return nil, err
|
end
|
end
|
|
local res = {}
|
|
for _, input in ipairs(inputs) do
|
local report = input.cached_report or input.new_report
|
|
if not report then
|
report = {fatal = input.fatal, msg = input.msg}
|
end
|
|
report.filename = input.filename
|
table.insert(res, report)
|
end
|
|
return res
|
end
|
|
function Runner:_get_final_report(reports)
|
local processing_options = {}
|
|
for index, report in ipairs(reports) do
|
if not report.fatal then
|
processing_options[index] = self._config_stack:get_options(report.filename)
|
end
|
end
|
|
local final_report = luacheck.process_reports(reports, processing_options, self._config_stack:get_stds())
|
|
-- `luacheck.process_reports` doesn't preserve `filename` fields, re-add them.
|
-- TODO: make it preserve them?
|
for index, report in ipairs(reports) do
|
final_report[index].filename = report.filename
|
end
|
|
return final_report
|
end
|
|
-- Inputs is an array of tables, each one specifies an input.
|
-- Each input table must have one of the following fields:
|
-- * `path`: string pointing to a file or directory to check. Checking directories requires LuaFileSystem,
|
-- and recursively checks all files within the directory. If `include_files` option is not used,
|
-- only files with `.lua` extensions within the directory are considered.
|
-- * `rockspec_path`: string pointing to a rockspec, all files with `.lua` extension within its `build.modules`,
|
-- `build.install.lua`, and `build.install.bin` tables are checked.
|
-- * `file`: an open file object. It is read till EOF and closed, contents are checked.
|
-- * `string`: Lua code to check as a string.
|
-- Additionally, each input table can have `filename` field: a string used when applying `exclude_files`
|
-- and `include_files` options to the input, and also when figuring out which per-path option overrides to use.
|
-- By default, if `path` field is given, it is also used as `filename`, otherwise the input is considered unnamed.
|
-- Unnamed files always pass `exclude_files` and `include_files` filters and don't have any per-path options applied.
|
function Runner:check(inputs)
|
local ok, err = validate_inputs(inputs)
|
|
if not ok then
|
error(("bad argument #1 to 'Runner:check' (%s)"):format(err))
|
end
|
|
-- Path-related top options can depend on current directory.
|
-- Assume it can't somehow change during `:check` call.
|
self._top_opts = self._config_stack:get_top_options()
|
|
local prepared_inputs = self:_prepare_inputs(inputs)
|
local reports, reports_err = self:_get_reports(prepared_inputs)
|
|
if not reports then
|
return nil, reports_err
|
end
|
|
return self:_get_final_report(reports)
|
end
|
|
-- Formats given report (same format as returned by `Runner:check`).
|
-- Optionally a table of options can be passed as `format_opts`,
|
-- it can contain options `formatter`. `quiet`, `color`, `codes`, and `ranges`,
|
-- with priority over options from initialization and config.
|
-- Returns formatted report as a string. It always has a newline at the end unless it is empty.
|
-- On error returns nil and an error message.
|
function Runner:format(report, format_opts)
|
if type(report) ~= "table" then
|
error(("bad argument #1 to 'Runner:format' (report table expected, got %s"):format(type(report)))
|
end
|
|
local is_valid, err = options.validate(config.format_options, format_opts)
|
|
if not is_valid then
|
error(("bad argument #2 to 'Runner:format' (%s)"):format(err))
|
end
|
|
local top_opts = self._config_stack:get_top_options()
|
format_opts = format_opts or {}
|
|
local combined_opts = {}
|
|
for _, option in ipairs({"formatter", "quiet", "color", "codes", "ranges"}) do
|
combined_opts[option] = top_opts[option]
|
|
if format_opts[option] ~= nil then
|
combined_opts[option] = format_opts[option]
|
end
|
end
|
|
local filenames = {}
|
|
for _, file_report in ipairs(report) do
|
table.insert(filenames, file_report.filename or "<unnamed source>")
|
end
|
|
local output
|
|
if format.builtin_formatters[combined_opts.formatter] then
|
output = format.format(report, filenames, combined_opts)
|
else
|
local formatter_func = combined_opts.formatter
|
|
if type(combined_opts.formatter) == "string" then
|
local require_ok
|
local formatter_anchor_dir
|
|
if not format_opts.formatter then
|
formatter_anchor_dir = top_opts.formatter_anchor_dir
|
end
|
|
require_ok, formatter_func = config.relative_require(formatter_anchor_dir, combined_opts.formatter)
|
|
if not require_ok then
|
return nil, ("Couldn't load custom formatter '%s': %s"):format(combined_opts.formatter, formatter_func)
|
end
|
end
|
|
local ok
|
ok, output = pcall(formatter_func, report, filenames, combined_opts)
|
|
if not ok then
|
return nil, ("Couldn't run custom formatter '%s': %s"):format(tostring(combined_opts.formatter), output)
|
end
|
end
|
|
if #output > 0 and output:sub(-1) ~= "\n" then
|
output = output .. "\n"
|
end
|
|
return output
|
end
|
|
return runner
|