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
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
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
error("input doesn't specify source to check")
end
end
return res
end
function Runner:_add_cached_reports(inputs)
for _, input in ipairs(inputs) do
if not input.fatal and input.path then
local report, err = self._cache:get(input.path)
if err then
input.fatal = "I/O"
input.msg = ("Couldn't load cache for %s from %s: malformed data"):format(
self._top_opts.cache, input.path)
else
input.cached_report = report
end
end
end
end
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
function Runner:_save_new_reports_to_cache(inputs)
for _, input in ipairs(inputs) do
if input.new_report and input.path then
local ok = self._cache:put(input.path, input.new_report)
if not ok then
return nil, ("Couldn't save cache for %s from %s: I/O error"):format(input.path, self._top_opts.cache)
end
end
end
return true
end
function Runner:_get_reports(inputs)
if self._top_opts.cache then
local err
self._cache, err = cache.new(self._top_opts.cache)
if not self._cache then
return nil, err
end
self:_add_cached_reports(inputs)
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())
for index, report in ipairs(reports) do
final_report[index].filename = report.filename
end
return final_report
end
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
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
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