From ccff2f76738c95bdc6b336ed8803606e93083a4f Mon Sep 17 00:00:00 2001 From: Lars Mueller Date: Sun, 20 Dec 2020 14:39:01 +0100 Subject: [PATCH] Clean configuration system --- Readme.md | 25 ++++- conf.lua | 38 +++---- init.lua | 1 + mod.lua | 62 +++++++++++- schema.lua | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 397 insertions(+), 20 deletions(-) create mode 100644 schema.lua diff --git a/Readme.md b/Readme.md index f6291b7..5edfb11 100644 --- a/Readme.md +++ b/Readme.md @@ -12,9 +12,32 @@ Mostly self-documenting code. Mod namespace is `modlib` or `_ml`, containing all ## Configuration +### Legacy + 1. Configuration is loaded from `/config/.`, the following extensions are supported and loaded (in the given order), with loaded configurations overriding properties of previous ones: 1. [`json`](https://json.org) 2. [`lua`](https://lua.org) 3. [`luon`](https://github.com/appgurueu/luon), Lua but without the `return` 4. [`conf`](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt) -2. Settings are loaded from `minetest.conf` and override configuration values \ No newline at end of file +2. Settings are loaded from `minetest.conf` and override configuration values + +### Locations + +0. Default configuration: `/conf.lua` +1. World configuration: `config/.` +2. Mod configuration: `/conf.` +3. Minetest configuration: `minetest.conf` + +### Formats + +1. [`lua`](https://lua.org) + * Lua, with the environment being the configuration object + * `field = value` works + * Return new configuration object to replace +2. [`luon`](https://github.com/appgurueu/luon) + * Single Lua literal + * Booleans, numbers, strings and tables +3. [`conf`](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt) + * Minetest-like configuration files +4. [`json`](https://json.org) + * Not recommended \ No newline at end of file diff --git a/conf.lua b/conf.lua index f0c2307..f2c3ca0 100644 --- a/conf.lua +++ b/conf.lua @@ -1,3 +1,22 @@ +-- not deprecated +function build_tree(dict) + local tree = {} + for key, value in pairs(dict) do + local path = modlib.text.split_unlimited(key, ".") + local subtree = tree + for i = 1, #path - 1 do + local index = tonumber(path[i]) or path[i] + subtree[index] = subtree[index] or {} + subtree = subtree[index] + end + subtree[path[#path]] = value + end + return tree +end +function build_setting_tree() + modlib.conf.settings = build_tree(minetest.settings:to_table()) +end +-- deprecated, use modlib.mod.configuration instead minetest.mkdir(minetest.get_worldpath().."/config") function get_path(confname) return minetest.get_worldpath().."/config/"..confname @@ -51,23 +70,6 @@ function read_conf(text) end return dict end -function build_tree(dict) - local tree = {} - for key, value in pairs(dict) do - local path = modlib.text.split_unlimited(key, ".") - local subtree = tree - for i = 1, #path - 1 do - local index = tonumber(path[i]) or path[i] - subtree[index] = subtree[index] or {} - subtree = subtree[index] - end - subtree[path[#path]] = value - end - return tree -end -function build_setting_tree() - modlib.conf.settings = build_tree(minetest.settings:to_table()) -end function check_config_constraints(config, constraints, handler) local no_error, error_or_retval = pcall(function() check_constraints(config, constraints) end) if not no_error then @@ -153,7 +155,7 @@ function generate_settingtypes(default_conf, constraints) local range = constraints.range if range then -- TODO consider better max - type_args = (constraints.int and "%d %d" or "%f %f"):format(range[1], range[2] or 2 ^ 30) + type_args = (constraints.int and "%d %d" or "%f %f"):format(range[1], range[2] or (2 ^ 30)) end -- HACK if not default_conf then default_conf = range[1] end diff --git a/init.lua b/init.lua index 2b30b66..203ddb7 100644 --- a/init.lua +++ b/init.lua @@ -57,6 +57,7 @@ local components = { mod = {}, class = {}, conf = {}, + schema = {}, data = {}, file = {}, func = {}, diff --git a/mod.lua b/mod.lua index 8434115..fb54027 100644 --- a/mod.lua +++ b/mod.lua @@ -13,7 +13,7 @@ function include(modname, file) file = modname modname = minetest.get_current_modname() end - dofile(get_resource(modname, file)) + return dofile(get_resource(modname, file)) end function include_env(file_or_string, env, is_string) @@ -52,4 +52,64 @@ function extend_string(modname, string) modname = minetest.get_current_modname() end include_env(string, _G[modname], true) +end + +function configuration(modname) + modname = modname or minetest.get_current_modname() + local schema = modlib.schema.new(assert(include(modname, "schema.lua"))) + schema.name = schema.name or modname + assert(schema.type == "table") + local overrides = {} + local conf + local function add(path) + for _, format in ipairs{ + {extension = "lua", read = function(text) + assert(overrides._C == nil) + local additions = setfenv(assert(loadstring(text)), setmetatable(overrides, {__index = {_C = overrides}}))() + setmetatable(overrides, nil) + if additions == nil then + return overrides + end + return additions + end}, + {extension = "luon", read = function(text) + local value = {setfenv(assert(loadstring("return " .. text)), setmetatable(overrides, {}))()} + assert(#value == 1) + value = value[1] + local function check_type(value) + local type = type(value) + if type == "table" then + assert(getmetatable(value) == nil) + for key, value in pairs(value) do + check_type(key) + check_type(value) + end + elseif not (type == "boolean" or type == "number" or type == "string") then + error("disallowed type " .. type) + end + end + check_type(value) + return value + end}, + {extension = "conf", read = function(text) return Settings(text):to_table() end, convert_strings = true}, + {extension = "json", read = minetest.parse_json} + } do + local content = modlib.file.read(path .. "." .. format.extension) + if content then + overrides = modlib.table.deep_add_all(overrides, format.read(content)) + conf = schema:load(overrides, {convert_strings = format.convert_strings, error_message = true}) + end + end + end + add(minetest.get_worldpath() .. "/conf/" .. modname) + add(get_resource(modname, "conf")) + local minetest_conf = modlib.conf.settings[schema.name] + if minetest_conf then + overrides = modlib.table.deep_add_all(overrides, minetest_conf) + conf = schema:load(overrides, {convert_strings = true, error_message = true}) + end + if conf == nil then + return schema:load({}, {error_message = true}) + end + return conf end \ No newline at end of file diff --git a/schema.lua b/schema.lua new file mode 100644 index 0000000..8a93cad --- /dev/null +++ b/schema.lua @@ -0,0 +1,291 @@ +local schema = getfenv(1) + +function new(def) + -- TODO type inference, sanity checking etc. + return setmetatable(def, {__index = schema}) +end + +local function field_name_to_title(name) + local title = modlib.text.split(name, "_") + title[1] = modlib.text.upper_first(title[1]) + return table.concat(title, " ") +end + +function generate_settingtypes(self) + local type = self.type + local settingtype, type_args + self.title = self.title or field_name_to_title(self.name) + self._level = self._level or 0 + local default = self.default + if type == "boolean" then + settingtype = "bool" + default = default and "true" or "false" + elseif type == "string" then + settingtype = "string" + elseif type == "number" then + settingtype = self.int and "int" or "float" + if self.min or self.max then + -- TODO handle exclusive min/max + type_args = (self.int and "%d %d" or "%f %f"):format(self.min or (2 ^ -30), self.max or (2 ^ 30)) + end + elseif type == "table" then + local handled = {} + local settings = {"[" .. table.concat(modlib.table.repetition("*", self._level)) .. self.name .. "]"} + local function setting(key, value_scheme) + if handled[key] then + return + end + handled[key] = true + assert(not key:find("[=%.%s]")) + value_scheme.name = self.name .. "." .. key + value_scheme.title = value_scheme.title or self.title .. " " .. field_name_to_title(key) + value_scheme._level = self._level + 1 + table.insert(settings, generate_settingtypes(value_scheme)) + end + local keys = {} + for key in pairs(self.entries or {}) do + table.insert(keys, key) + end + table.sort(keys) + for _, key in ipairs(keys) do + setting(key, self.entries[key]) + end + return table.concat(settings, "\n") + end + if not type then + return "" + end + local description = self.description + -- TODO extend description by range etc.? + -- TODO enum etc. support + if description then + if type(description) ~= "table" then + description = {description} + end + description = "# " .. table.concat(description, "\n# ") .. "\n" + else + description = "" + end + return description .. self.name .. " (" .. self.title .. ") " .. settingtype .. " " .. (default or "") .. (type_args and (" " .. type_args) or "") +end + +function generate_markdown(self) + -- TODO address redundancies + local typ = self.type + self.title = self.title or field_name_to_title(self._md_name) + self._level = self._level or 1 + if typ == "table" then + local handled = {} + local settings = {} + local function setting(key, value_scheme) + if handled[key] then + return + end + handled[key] = true + value_scheme._md_name = key + value_scheme.title = value_scheme.title or self.title .. " " .. field_name_to_title(key) + value_scheme._level = self._level + 1 + table.insert(settings, table.concat(modlib.table.repetition("#", self._level)) .. " `" .. key .. "`") + table.insert(settings, generate_markdown(value_scheme)) + table.insert(settings, "") + end + local keys = {} + for key in pairs(self.entries or {}) do + table.insert(keys, key) + end + table.sort(keys) + for _, key in ipairs(keys) do + setting(key, self.entries[key]) + end + return table.concat(settings, "\n") + end + if not typ then + return "" + end + local lines = {} + local function line(text) + table.insert(lines, "* " .. text) + end + local description = self.description + if description then + modlib.table.append(lines, type(description) == "table" and description or {description}) + end + table.insert(lines, "") + line("Type: " .. self.type) + if self.default ~= nil then + line("Default: `" .. tostring(self.default) .. "`") + end + if self.int then + line"Integer" + elseif self.list then + line"List" + end + if self.infinity then + line"Infinities allowed" + end + if self.nan then + line"Not-a-Number (NaN) allowed" + end + if self.range then + if self.range.min then + line("> " .. self.range.min) + elseif self.range.min_exclusive then + line(">= " .. self.range.min_exclusive) + end + if self.range.max then + line("< " .. self.range.max) + elseif self.range.max_exclusive then + line("<= " .. self.range.max_exclusive) + end + end + if self.values then + line("Possible values:") + for value in pairs(self.values) do + table.insert(lines, " * " .. value) + end + end + return table.concat(lines, "\n") +end + +function settingtypes(self) + self.settingtypes = self.settingtypes or generate_settingtypes(self) + return self.settingtypes +end + +function load(self, override, params) + local converted + if params.convert_strings and type(override) == "string" then + converted = true + if self.type == "boolean" then + if override == "true" then + override = true + elseif override == "false" then + override = false + end + elseif self.type == "number" then + override = tonumber(override) + else + converted = false + end + end + if override == nil and not converted then + if self.default ~= nil then + return self.default + elseif self.type == "table" then + override = {} + end + end + local _error = error + local function format_error(type, ...) + if type == "type" then + return "mismatched type: expected " .. self.type ..", got " .. type(override) .. (converted and " (converted)" or "") + end + if type == "range" then + local conditions = {} + local function push(condition, bound) + if self.range[bound] then + table.insert(conditions, " " .. condition .. " " .. minetest.write_json(self.range[bound])) + end + end + push(">", "min_exclusive") + push(">=", "min") + push("<", "max_exclusive") + push("<=", "max") + return "out of range: expected value " .. table.concat(conditions, "and") + end + if type == "int" then + return "expected integer" + end + if type == "infinity" then + return "expected no infinity" + end + if type == "nan" then + return "expected no nan" + end + if type == "required" then + local key = ... + return "required field " .. minetest.write_json(key) .. " missing" + end + if type == "additional" then + local key = ... + return "superfluous field " .. minetest.write_json(key) + end + if type == "list" then + return "not a list" + end + if type == "values" then + return "expected one of " .. minetest.write_json(modlib.table.keys(self.values)) .. ", got " .. minetest.write_json(override) + end + _error("unknown error type") + end + local function error(type, ...) + if params.error_message then + local formatted = format_error(type, ...) + settingtypes(self) + _error("Invalid value: " .. self.name .. ": " .. formatted) + end + _error{ + type = type, + self = self, + override = override, + converted = converted + } + end + local function assert(value, ...) + if not value then + error(...) + end + return value + end + assert(self.type == type(override), "type") + if self.type == "number" or self.type == "string" then + if self.range then + if self.range.min then + assert(self.range.min <= override, "range") + elseif self.range.min_exclusive then + assert(self.range.min_exclusive < override, "range") + end + if self.range.max then + assert(self.range.max >= override, "range") + elseif self.range.max_exclusive then + assert(self.range.max_exclusive > override, "range") + end + end + if self.type == "number" then + assert((not self.int) or (override % 1 == 0), "int") + assert(self.infinity or math.abs(override) ~= math.huge, "infinity") + assert(self.nan or override == override, "nan") + end + elseif self.type == "table" then + if self.entries then + for key, schema in pairs(self.entries) do + if schema.required and override[key] == nil then + error("required", key) + end + override[key] = load(schema, override[key], params) + end + if self.additional == false then + for key in pairs(override) do + if self.entries[key] == nil then + error("additional", key) + end + end + end + end + if self.keys then + for key, value in pairs(override) do + override[load(self.keys, key, params)], override[key] = value, nil + end + end + if self.values then + for key, value in pairs(override) do + override[key] = load(self.values, value, params) + end + end + assert((not self.list) or modlib.table.count(override) == #override, "list") + else + assert((not self.values) or self.values[override], "values") + end + if self.func then self.func(override) end + return override +end \ No newline at end of file