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