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 "") 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