local options = require "luacheck.options"
|
local builtin_standards = require "luacheck.builtin_standards"
|
local fs = require "luacheck.fs"
|
local globbing = require "luacheck.globbing"
|
local standards = require "luacheck.standards"
|
local utils = require "luacheck.utils"
|
|
local config = {}
|
|
local function get_global_config_dir()
|
if utils.is_windows then
|
local local_app_data_dir = os.getenv("LOCALAPPDATA")
|
|
if not local_app_data_dir then
|
local user_profile_dir = os.getenv("USERPROFILE")
|
|
if user_profile_dir then
|
local_app_data_dir = fs.join(user_profile_dir, "Local Settings", "Application Data")
|
end
|
end
|
|
if local_app_data_dir then
|
return fs.join(local_app_data_dir, "Luacheck")
|
end
|
else
|
local fh = assert(io.popen("uname -s"))
|
local system = fh:read("*l")
|
fh:close()
|
|
if system == "Darwin" then
|
local home_dir = os.getenv("HOME")
|
|
if home_dir then
|
return fs.join(home_dir, "Library", "Application Support", "Luacheck")
|
end
|
else
|
local config_home_dir = os.getenv("XDG_CONFIG_HOME")
|
|
if not config_home_dir then
|
local home_dir = os.getenv("HOME")
|
|
if home_dir then
|
config_home_dir = fs.join(home_dir, ".config")
|
end
|
end
|
|
if config_home_dir then
|
return fs.join(config_home_dir, "luacheck")
|
end
|
end
|
end
|
end
|
|
config.default_path = ".luacheckrc"
|
|
function config.get_default_global_path()
|
local global_config_dir = get_global_config_dir()
|
|
if global_config_dir then
|
return fs.join(global_config_dir, config.default_path)
|
end
|
end
|
|
-- A single config is represented by a table with fields:
|
-- * `options`: table with all config scope options, including `stds` and `files`.
|
-- * `config_path`: optional path to file from which config was loaded, used only in error messages.
|
-- * `anchor_dir`: absolute path to directory relative to which config was loaded,
|
-- or nil if the config is not anchored. Paths within a config are adjusted to be absolute
|
-- relative to anchor directory, or current directory if it's not anchored.
|
-- As current directory can change between config usages, this adjustment happens on demand.
|
|
-- Returns config path and optional anchor directory or nil and optional error message.
|
local function locate_config(path, global_path)
|
if path == false then
|
return
|
end
|
|
local is_default_path = not path
|
path = path or config.default_path
|
|
if fs.is_absolute(path) then
|
return path
|
end
|
|
local current_dir = fs.get_current_dir()
|
local anchor_dir, rel_dir = fs.find_file(current_dir, path)
|
|
if anchor_dir then
|
return fs.join(rel_dir, path), anchor_dir
|
end
|
|
if not is_default_path then
|
return nil, ("Couldn't find configuration file %s"):format(path)
|
end
|
|
if global_path == false then
|
return
|
end
|
|
global_path = global_path or config.get_default_global_path()
|
|
if global_path and fs.is_file(global_path) then
|
return global_path, (fs.split_base(global_path))
|
end
|
end
|
|
local function try_load(path)
|
local src = utils.read_file(path)
|
|
if not src then
|
return
|
end
|
|
local func, err = utils.load(src, nil, "@"..path)
|
return err or func
|
end
|
|
local function add_relative_loader(anchor_dir)
|
if not anchor_dir then
|
return
|
end
|
|
local function loader(modname)
|
local modpath = fs.join(anchor_dir, (modname:gsub("%.", utils.dir_sep)))
|
return try_load(modpath..".lua") or try_load(modpath..utils.dir_sep.."init.lua"), modname
|
end
|
|
table.insert(package.loaders or package.searchers, 1, loader) -- luacheck: compat
|
return loader
|
end
|
|
local function remove_relative_loader(loader)
|
if not loader then
|
return
|
end
|
|
for i, func in ipairs(package.loaders or package.searchers) do -- luacheck: compat
|
if func == loader then
|
table.remove(package.loaders or package.searchers, i) -- luacheck: compat
|
return
|
end
|
end
|
end
|
|
-- Requires module from config anchor directory.
|
-- Returns success flag and module or error message.
|
function config.relative_require(anchor_dir, modname)
|
local loader = add_relative_loader(anchor_dir)
|
local ok, mod_or_err = pcall(require, modname)
|
remove_relative_loader(loader)
|
return ok, mod_or_err
|
end
|
|
-- Config must support special metatables for some keys:
|
-- autovivification for `files`, fallback to built-in stds for `stds`.
|
|
local special_mts = {
|
stds = {__index = builtin_standards},
|
files = {__index = function(files, key)
|
files[key] = {}
|
return files[key]
|
end}
|
}
|
|
local function make_config_env_mt()
|
local env_mt = {}
|
local special_values = {}
|
|
for key, mt in pairs(special_mts) do
|
special_values[key] = setmetatable({}, mt)
|
end
|
|
function env_mt.__index(_, key)
|
if special_mts[key] then
|
return special_values[key]
|
else
|
return _G[key]
|
end
|
end
|
|
function env_mt.__newindex(env, key, value)
|
if special_mts[key] then
|
if type(value) == "table" then
|
setmetatable(value, special_mts[key])
|
end
|
|
special_values[key] = value
|
else
|
rawset(env, key, value)
|
end
|
end
|
|
return env_mt, special_values
|
end
|
|
local function make_config_env()
|
local mt, special_values = make_config_env_mt()
|
return setmetatable({}, mt), special_values
|
end
|
|
local function remove_env_mt(env, special_values)
|
setmetatable(env, nil)
|
utils.update(env, special_values)
|
end
|
|
local function set_default_std(files, pattern, std)
|
-- Avoid mutating option tables, they may be shared between different patterns.
|
local pattern_opts = {std = std}
|
|
if files[pattern] then
|
pattern_opts = utils.update(pattern_opts, files[pattern])
|
end
|
|
files[pattern] = pattern_opts
|
end
|
|
local function add_default_path_options(opts)
|
local files = {}
|
|
if opts.files then
|
files = utils.update(files, opts.files)
|
end
|
|
opts.files = files
|
set_default_std(files, "**/spec/**/*_spec.lua", "+busted")
|
set_default_std(files, "**/test/**/*_spec.lua", "+busted")
|
set_default_std(files, "**/tests/**/*_spec.lua", "+busted")
|
set_default_std(files, "**/*.rockspec", "+rockspec")
|
set_default_std(files, "**/*.luacheckrc", "+luacheckrc")
|
end
|
|
local fallback_config = {options = {}, anchor_dir = ""}
|
add_default_path_options(fallback_config.options)
|
|
-- Loads config from a file, if possible.
|
-- `path` and `global_path` can be nil (will use default), false (will disable loading), or a string.
|
-- Doesn't validate the config.
|
-- Returns a table or nil and an error message.
|
function config.load_config(path, global_path)
|
local config_path, anchor_dir = locate_config(path, global_path)
|
|
if not config_path then
|
if anchor_dir then
|
return nil, anchor_dir
|
else
|
return fallback_config
|
end
|
end
|
|
local env, special_values = make_config_env()
|
local loader = add_relative_loader(anchor_dir)
|
local load_ok, ret, load_err = utils.load_config(config_path, env)
|
remove_relative_loader(loader)
|
|
if not load_ok then
|
return nil, ("Couldn't load configuration from %s: %s error (%s)"):format(config_path, ret, load_err)
|
end
|
|
-- Support returning some options from config instead of setting them as globals.
|
-- This allows easily loading options from another file, for example using require.
|
if type(ret) == "table" then
|
utils.update(env, ret)
|
end
|
|
remove_env_mt(env, special_values)
|
add_default_path_options(env)
|
return {options = env, config_path = config_path, anchor_dir = anchor_dir}
|
end
|
|
function config.table_to_config(opts)
|
return {options = opts}
|
end
|
|
-- Validates custom stds within a config table and adds them to stds map.
|
-- Returns true on success or nil and an error message on error.
|
local function add_stds_from_config(conf, stds)
|
if conf.options.stds ~= nil then
|
if type(conf.options.stds) ~= "table" then
|
return nil, ("invalid option 'stds': table expected, got %s"):format(type(conf.options.stds))
|
end
|
|
-- Validate stds in sorted order for deterministic output when more than one std is invalid.
|
local std_names = {}
|
|
for std_name in pairs(conf.options.stds) do
|
if type(std_name) == "string" then
|
table.insert(std_names, std_name)
|
end
|
end
|
|
table.sort(std_names)
|
|
for _, std_name in ipairs(std_names) do
|
local std = conf.options.stds[std_name]
|
|
if type(std) ~= "table" then
|
return nil, ("invalid custom std '%s': table expected, got %s"):format(std_name, type(std))
|
end
|
|
local ok, err = standards.validate_std_table(std)
|
|
if not ok then
|
return nil, ("invalid custom std '%s': %s"):format(std_name, err)
|
end
|
|
stds[std_name] = std
|
end
|
end
|
|
return true
|
end
|
|
local function error_prefix(conf)
|
if conf.config_path then
|
return ("in config loaded from %s: "):format(conf.config_path)
|
else
|
return ""
|
end
|
end
|
|
local function quiet_validator(x)
|
if type(x) == "number" then
|
if math.floor(x) == x and x >= 0 and x <= 3 then
|
return true
|
else
|
return false, ("integer in range 0..3 expected, got %.20g"):format(x)
|
end
|
else
|
return false, ("integer in range 0..3 expected, got %s"):format(type(x))
|
end
|
end
|
|
local function jobs_validator(x)
|
if type(x) == "number" then
|
if math.floor(x) == x and x >= 1 then
|
return true
|
else
|
return false, ("positive integer expected, got %.20g"):format(x)
|
end
|
else
|
return false, ("positive integer expected, got %s"):format(type(x))
|
end
|
end
|
|
config.format_options = {
|
quiet = quiet_validator,
|
color = utils.has_type("boolean"),
|
codes = utils.has_type("boolean"),
|
ranges = utils.has_type("boolean"),
|
formatter = utils.has_either_type("string", "function")
|
}
|
|
local top_options = {
|
cache = utils.has_either_type("string", "boolean"),
|
jobs = jobs_validator,
|
files = utils.has_type("table"),
|
stds = utils.has_type("table"),
|
exclude_files = utils.array_of("string"),
|
include_files = utils.array_of("string")
|
}
|
|
utils.update(top_options, config.format_options)
|
utils.update(top_options, options.all_options)
|
|
-- Returns true if config is valid, nil and error message otherwise.
|
local function validate_config(conf, stds)
|
local ok, err = options.validate(top_options, conf.options, stds)
|
|
if not ok then
|
return nil, err
|
end
|
|
if conf.options.files then
|
for path, opts in pairs(conf.options.files) do
|
if type(path) == "string" then
|
ok, err = options.validate(options.all_options, opts, stds)
|
|
if not ok then
|
return nil, ("invalid options for path '%s': %s"):format(path, err)
|
end
|
end
|
end
|
end
|
|
return true
|
end
|
|
local ConfigStack = utils.class()
|
|
function ConfigStack:__init(configs, stds)
|
self._configs = configs
|
self._stds = stds
|
end
|
|
function ConfigStack:get_stds()
|
return self._stds
|
end
|
|
-- Accepts an array of config tables, as returned from `load_config` and `table_to_config`.
|
-- Assumes that configs closer to end of the array override configs closer to beginning.
|
-- Returns an instance of `ConfigStack`. On validation error returns nil and an error message.
|
function config.stack_configs(configs)
|
-- First, collect and validate stds from all configs, they are required to validate `std` option.
|
local stds = utils.update({}, builtin_standards)
|
|
for _, conf in ipairs(configs) do
|
local ok, err = add_stds_from_config(conf, stds)
|
|
if not ok then
|
return nil, error_prefix(conf) .. err
|
end
|
end
|
|
for _, conf in ipairs(configs) do
|
local ok, err = validate_config(conf, stds)
|
|
if not ok then
|
return nil, error_prefix(conf) .. err
|
end
|
end
|
|
return ConfigStack(configs, stds)
|
end
|
|
-- Returns a table of top-level config options, except `files` and `stds`.
|
function ConfigStack:get_top_options()
|
local res = {
|
quiet = 0,
|
color = true,
|
codes = false,
|
ranges = false,
|
formatter = "default",
|
cache = false,
|
jobs = false,
|
include_files = {},
|
exclude_files = {}
|
}
|
|
local current_dir = fs.get_current_dir()
|
local last_anchor_dir
|
|
for _, conf in ipairs(self._configs) do
|
for _, option in ipairs({"quiet", "color", "codes", "ranges", "jobs"}) do
|
if conf.options[option] ~= nil then
|
res[option] = conf.options[option]
|
end
|
end
|
|
-- It's not immediately obvious relatively to which config formatter modules
|
-- should be resolved when they are specified in a config without an anchor dir.
|
-- For now, use the last anchor directory available, that should result
|
-- in reasonable behaviour in the current case of a single anchored config (loaded from file)
|
-- + a single not anchored config (loaded from CLI options).
|
last_anchor_dir = conf.anchor_dir or last_anchor_dir
|
|
if conf.options.formatter ~= nil then
|
res.formatter = conf.options.formatter
|
res.formatter_anchor_dir = last_anchor_dir
|
end
|
|
-- Path options, on the other hand, are interpreted relatively to the current directory
|
-- when specified in a config without anchor. Behaviour similar to formatter could also
|
-- make sense, but this is consistent with pre 0.22.0 behaviou
|
local anchor_dir = conf.anchor_dir or current_dir
|
|
for _, option in ipairs({"include_files", "exclude_files"}) do
|
if conf.options[option] ~= nil then
|
for _, glob in ipairs(conf.options[option]) do
|
table.insert(res[option], fs.normalize(fs.join(anchor_dir, glob)))
|
end
|
end
|
end
|
|
if conf.options.cache ~= nil then
|
if conf.options.cache == true then
|
if not res.cache then
|
res.cache = fs.normalize(fs.join(last_anchor_dir or current_dir, ".luacheckcache"))
|
end
|
elseif conf.options.cache == false then
|
res.cache = false
|
else
|
res.cache = fs.normalize(fs.join(anchor_dir, conf.options.cache))
|
end
|
end
|
end
|
|
return res
|
end
|
|
local function add_applying_overrides(option_stack, conf, filename)
|
if not filename or not conf.options.files then
|
return
|
end
|
|
local current_dir = fs.get_current_dir()
|
local abs_filename = fs.normalize(fs.join(current_dir, filename))
|
local anchor_dir
|
|
if conf.anchor_dir == "" then
|
anchor_dir = fs.split_base(current_dir)
|
else
|
anchor_dir = conf.anchor_dir or current_dir
|
end
|
|
local matching_pairs = {}
|
|
for glob, opts in pairs(conf.options.files) do
|
if type(glob) == "string" then
|
local abs_glob = fs.normalize(fs.join(anchor_dir, glob))
|
|
if globbing.match(abs_glob, abs_filename) then
|
table.insert(matching_pairs, {
|
abs_glob = abs_glob,
|
opts = opts
|
})
|
end
|
end
|
end
|
|
table.sort(matching_pairs, function(pair1, pair2)
|
return globbing.compare(pair1.abs_glob, pair2.abs_glob)
|
end)
|
|
for _, pair in ipairs(matching_pairs) do
|
table.insert(option_stack, pair.opts)
|
end
|
end
|
|
-- Returns an option stack applicable to a file with given name, or in general if name is not given.
|
function ConfigStack:get_options(filename)
|
local res = {}
|
|
for _, conf in ipairs(self._configs) do
|
table.insert(res, conf.options)
|
add_applying_overrides(res, conf, filename)
|
end
|
|
return res
|
end
|
|
return config
|