From b2399d68257540fc30f556d823929d728d8b1cb6 Mon Sep 17 00:00:00 2001 From: luk3yx Date: Sun, 20 Oct 2019 14:57:16 +1300 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE.md | 22 ++ README.md | 182 ++++++++++++++++ core.lua | 548 +++++++++++++++++++++++++++++++++++++++++++++++ elements.lua | 6 + helpers.lua | 206 ++++++++++++++++++ init.lua | 60 ++++++ lua_dump.py | 104 +++++++++ make_elements.py | 157 ++++++++++++++ mod.conf | 2 + safety.lua | 196 +++++++++++++++++ 11 files changed, 1485 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 core.lua create mode 100644 elements.lua create mode 100644 helpers.lua create mode 100644 init.lua create mode 100644 lua_dump.py create mode 100755 make_elements.py create mode 100644 mod.conf create mode 100644 safety.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d6528a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +test.lua diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..61e68fa --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ + +# The MIT License (MIT) + +Copyright © 2019 by luk3yx. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e26ee7a --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# formspec_ast + +A Minetest mod library to make modifying formspecs easier. + +## API + + - `formspec_ast.parse(formspec_string)`: Parses `formspec_string` and returns + an AST. + - `formspec_ast.unparse(tree)`: Unparses the abstract syntax tree provided + and returns a formspec string. + - `formspec_ast.register_element('modname:element', function())`: Registers + a custom element. See [registering elements] for more information. + - `formspec_ast.interpret(string_or_tree)`: Returns a formspec string after + (optionally parsing) and unparsing the formspec provided. + - `formspec_ast.walk(tree)`: Returns an iterator (use this directly in a for + loop) that will return all nodes in a tree, including ones in side + containers. + - `formspec_ast.find(tree, node_type)`: Similar to `walk(tree)`, however only + returns `node_type` nodes. + - `formspec_ast.get_element_by_name(tree, name)`: Returns the first element in + the tree with the name `name`. + - `formspec_ast.get_elements_by_name(tree, name)`: Returns a list of all the + elements with the name `name`. + - `formspec_ast.apply_offset(tree, x, y)`: Shifts all elements in `tree`. + Similar to `container`. + - `formspec_ast.flatten(tree)`: Removes all containers and offsets elements + that were in containers accordingly. + - `formspec_ast.show_formspec(player_or_name, formname, formspec)`: Similar + to `minetest.show_formspec`, however also accepts player objects and will + pass `formspec` through `formspec_ast.interpret` first. + - `formspec_ast.safe_parse(string_or_tree)`: Similar to `formspec_ast.parse`, + however will delete any elements that may crash the client (or any I + haven't added to the whitelist). + +## AST + +The AST is similar (and generated from) [the formspec element list], however +all attributes are lowercase. + +[the formspec element list]: https://github.com/minetest/minetest/blob/dee2210/doc/lua_api.txt#L1959 + +### Special cases + + - `formspec_version` (provided it is the first element) is moved to + `tree.formspec_version` (`1` by default). + +### `formspec_ast.parse` example + +*Note that the whitespace in the formspec is optional and exists for +readability. Non-numeric table items in the `dump()` output are re-ordered for +readability.* + +```lua +> tree = formspec_ast.parse('size[5,2] ' +>> .. 'style[name;bgcolor=blue;textcolor=yellow]' +>> .. 'container[1,1]' +>> .. ' label[0,0;Containers are fun]' +>> .. ' container[-1,-1]' +>> .. ' button[0.5,0;4,1;name;Label]' +>> .. ' container_end[]' +>> .. ' label[0,1;Nested containers work too.]' +>> .. 'container_end[]' +>> .. ' image[0,1;1,1;air.png]') +> print(dump(tree)) +{ + formspec_version = 1, + { + type = "size", + w = 5, + h = 2, + }, + { + type = "style", + name = "name", + props = { + bgcolor = "blue", + textcolor = "yellow", + }, + }, + { + type = "container", + x = 1, + y = 1, + { + type = "label", + x = 0, + y = 0, + label = "Containers are fun", + }, + { + type = "container", + x = -1, + y = -1, + { + type = "button", + x = 0.5, + y = 0, + w = 4, + h = 1, + name = "name", + label = "Label", + }, + }, + { + type = "label", + x = 0, + y = 1, + label = "Nested containers work too.", + }, + }, + { + type = "image", + x = 0, + y = 1, + w = 1, + h = 1, + texture_name = "air.png", + }, +} + +``` + +### `formspec_ast.flatten` example + +```lua +> print(dump(formspec_ast.flatten(tree))) +{ + formspec_version = 1, + { + type = "size", + w = 5, + h = 2, + }, + { + type = "style", + name = "name", + props = { + bgcolor = "blue", + textcolor = "yellow", + }, + }, + { + type = "label", + x = 1, + y = 1, + label = "Containers are fun", + }, + { + type = "button", + x = 0.5, + y = 0, + w = 4 + h = 1, + name = "name", + label = "Label", + }, + { + type = "label", + x = 1, + y = 2, + label = "Nested containers work too.", + }, + { + type = "image", + x = 0, + y = 1, + w = 1, + h = 1, + texture_name = "air.png", + }, +} +``` + +### `formspec_ast.unparse` example + +```lua +> print(formspec_ast.unparse(tree)) +size[5,2,]style[name;textcolor=yellow;bgcolor=blue]container[1,1]label[0,0;Containers are fun]container[-1,-1]button[0.5,0;4,1;name;Label]container_end[]label[0,1;Nested containers work too.]container_end[]image[0,1;1,1;air.png] + +> print(formspec_ast.unparse(formspec_ast.flatten(tree))) +size[5,2,]style[name;textcolor=yellow;bgcolor=blue]label[1,1;Containers are fun]button[0.5,0;4,1;name;Label]label[1,2;Nested containers work too.]image[0,1;1,1;air.png] +``` diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..76e04ee --- /dev/null +++ b/core.lua @@ -0,0 +1,548 @@ +-- +-- formspec_ast: An abstract system tree for formspecs. +-- +-- This does not actually depend on Minetest and could probably run in +-- standalone Lua. +-- +-- The MIT License (MIT) +-- +-- Copyright © 2019 by luk3yx. +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to +-- deal in the Software without restriction, including without limitation the +-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +-- sell copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +-- IN THE SOFTWARE. +-- + +local formspec_ast, minetest = formspec_ast, formspec_ast.minetest + +-- Parse a formspec into a "raw" non-AST state. +-- Input: size[5,2]button[0,0;5,1;name;Label] image[0,1;1,1;air.png] +-- Output: {{'size', '5', '2'}, {'button', '0,0', '5,1', 'name', 'label'}, +-- {'image', '0,1', '1,1', 'air.png'}} +local function raw_parse(spec) + local res = {} + while spec do + -- Get the first element + local name + name, spec = spec:match('([%w_%-:]*[^%s\\])%s*(%[.*)') + if not name or not spec then return res end + local elem = {} + elem[1] = name + + -- Get the parameters + local s, e = spec:find('[^\\]%]') + local rawargs + if s and e then + rawargs, spec = spec:sub(2, s), spec:sub(e + 1) + else + rawargs, spec = spec:sub(2), false + end + + -- Split everything + -- TODO: Make this a RegEx + local i = '' + local esc = false + local inner = {} + for c = 1, #rawargs do + local char = rawargs:sub(c, c) + if esc then + esc = false + i = i .. char + elseif char == '\\' then + esc = true + elseif char == ';' then + if #inner > 0 then + table.insert(inner, i) + table.insert(elem, inner) + inner = {} + else + table.insert(elem, i) + end + i = '' + elseif char == ',' then + table.insert(inner, i) + i = '' + else + i = i .. char + end + end + if i ~= '' or #elem > 1 then + if #inner > 0 then + table.insert(inner, i) + table.insert(elem, inner) + inner = {} + else + table.insert(elem, i) + end + end + + table.insert(res, elem) + end + + return res +end + +-- Unparse raw formspecs +-- WARNING: This will modify the table passed to it. +local function raw_unparse(data) + local res = '' + for _, elem in ipairs(data) do + for i = 2, #elem do + if type(elem[i]) == 'table' then + for j, e in ipairs(elem[i]) do + elem[i][j] = minetest.formspec_escape(e) + end + elem[i] = table.concat(elem[i], ',') + else + elem[i] = minetest.formspec_escape(elem[i]) + end + end + res = res .. table.remove(elem, 1) .. '[' .. + table.concat(elem, ';') .. ']' + end + return res +end + +-- Elements +-- The element format is currently not intuitive. +local elements = assert(loadfile(formspec_ast.modpath .. '/elements.lua'))() + +-- Parsing +local types = {} + +function types.null() end +function types.undefined() + error('Unknown element type!') +end + +function types.string(str) + return str +end + +function types.number(raw_num) + local num = tonumber(raw_num) + assert(num and num == num, 'Invalid number: "' .. raw_num .. '".') + return num +end + +function types.boolean(bool) + if bool ~= '' then + return minetest.is_yes(bool) + end +end + +function types.table(obj) + local s, e = obj:find('=', nil, true) + assert(s, 'Invalid syntax: "' .. obj .. '".') + return {[obj:sub(1, s - 1)] = obj:sub(e + 1)} +end + +function types.null(null) + assert(null:trim() == '', 'No value expected!') +end + +local function parse_value(elems, template) + local elems_l, template_l = #elems, #template + if elems_l < template_l or (elems_l > template_l and + template[template_l][2] ~= '...') then + while #elems > #template and elems[#elems]:trim() == '' do + elems[#elems] = nil + end + + assert(#elems == #template, 'Bad element length.') + end + + local res = {} + if elems.type then res.type = elems.type end + for i, obj in ipairs(template) do + local val + if obj[2] == '...' then + assert(template[i + 1] == nil, 'Invalid template!') + + local elems2 = {} + for j = i, #elems do + table.insert(elems2, elems[j]) + end + types['...'](elems2, obj[1], res) + elseif type(obj[2]) == 'string' then + local func = types[obj[2]] or types.undefined + local elem = elems[i] + if type(elem) == 'table' then + elem = table.concat(elem, ',') + end + res[obj[1]] = func(elem, obj[1]) + else + local elem = elems[i] + if type(elem) == 'string' then + elem = {elem} + end + while #obj > #elem do + table.insert(elem, '') + end + val = parse_value(elem, obj) + for k, v in pairs(val) do + res[k] = v + end + end + end + return res +end + +types['...'] = function(elems, obj, res) + local template = {obj} + local val = {} + local is_string = type(obj[2]) == 'string' + for i, elem in ipairs(elems) do + local n = parse_value({elem}, template) + if is_string then + n = n[obj[1]] + end + table.insert(val, n) + end + + if obj[2] == 'table' then + local t = {} + for _, n in ipairs(val) do + local k, v = next(n) + t[k] = v + end + res[obj[1] .. 's'] = t + elseif type(obj[2]) == 'string' then + res[obj[1]] = val + else + assert(type(val) == 'table') + res[res.type or 'data'] = val + end +end + +local parse_mt +local function parse_elem(elem, custom_handlers) + elem.type = table.remove(elem, 1) + local data = elements[elem.type] + if not data then + if not custom_handlers or not custom_handlers[elem.type] then + return false, 'Unknown element "' .. tostring(elem.type) .. '".' + end + local parse = {} + setmetatable(parse, parse_mt) + local good, ast_elem = pcall(custom_handlers[elem.type], elem, parse) + if good and (not ast_elem or not ast_elem.type) then + good, ast_elem = false, "Function didn't return AST element!" + end + if good then + return ast_elem + else + table.insert(elem, 1, elem.type) + return nil, 'Invalid element "' .. raw_unparse({elem}) .. '": ' .. + tostring(ast_elem) + end + end + + local good, ast_elem + for i, template in ipairs(data) do + if type(template) == 'function' then + good, ast_elem = pcall(template, elem) + if good and (not ast_elem or not ast_elem.type) then + good, ast_elem = false, "Function didn't return AST element!" + end + else + good, ast_elem = pcall(parse_value, elem, template) + end + if good then + return ast_elem + end + end + + table.insert(elem, 1, elem.type) + return nil, 'Invalid element "' .. raw_unparse({elem}) .. '": ' .. + tostring(ast_elem) +end + +-- Parse a formspec into a formspec AST. +-- Input: size[5,2] style[name;bgcolor=blue;textcolor=yellow] +-- button[0,0;5,1;name;Label] image[0,1;1,1;air.png] +-- Output: +-- { +-- formspec_version = 1, +-- { +-- type = "size", +-- w = 5, +-- h = 2, +-- }, +-- { +-- type = "style", +-- name = "name", +-- props = { +-- bgcolor = "blue", +-- textcolor = "yellow", +-- }, +-- }, +-- { +-- type = "button", +-- x = 0, +-- y = 0, +-- w = 5, +-- h = 1, +-- name = "name", +-- label = "Label", +-- }, +-- { +-- type = "image", +-- x = 0, +-- y = 1, +-- w = 1, +-- h = 1, +-- texture_name = "air.png", +-- } +-- } + + +function formspec_ast.parse(spec, custom_handlers) + spec = raw_parse(spec) + local res = {formspec_version=1} + local containers = {} + local container = res + for _, elem in ipairs(spec) do + local ast_elem, err = parse_elem(elem, custom_handlers) + if not ast_elem then + return nil, err + end + table.insert(container, ast_elem) + if ast_elem.type == 'container' then + table.insert(containers, container) + container = ast_elem + elseif ast_elem.type == 'container_end' then + container[#container] = nil + container = table.remove(containers) + if not container then + return nil, 'Mismatched container_end[]!' + end + end + end + + if res[1] and res[1].type == 'formspec_version' then + res.formspec_version = table.remove(res, 1).version + end + return res +end + +-- Unparsing +local function unparse_ellipsis(elem, obj1, res, inner) + if obj1[2] == 'table' then + local value = elem[obj1[1] .. 's'] + assert(type(value) == 'table', 'Invalid AST!') + for k, v in pairs(value) do + table.insert(res, tostring(k) .. '=' .. tostring(v)) + end + elseif type(obj1[2]) == 'string' then + local value = elem[obj1[1]] + for k, v in ipairs(value) do + table.insert(res, tostring(v)) + end + else + assert(inner == nil) + local data = elem[elem.type or 'data'] or elem + for _, elem2 in ipairs(data) do + local r = {} + for i, obj2 in ipairs(obj1) do + if obj2[2] == '...' then + unparse_ellipsis(elem2, obj2[1], r, true) + elseif type(obj2[2]) == 'string' then + table.insert(r, tostring(elem2[obj2[1]])) + end + end + table.insert(res, r) + end + end +end + +local function unparse_value(elem, template) + local res = {} + for i, obj in ipairs(template) do + if obj[2] == '...' then + assert(template[i + 1] == nil, 'Invalid template!') + unparse_ellipsis(elem, obj[1], res) + elseif type(obj[2]) == 'string' then + local value = elem[obj[1]] + if value == nil then + res[i] = '' + else + res[i] = tostring(value) + end + else + res[i] = unparse_value(elem, obj) + end + end + return res +end + +local compare_blanks +do + local function get_nonempty(a) + local nonempty = 0 + for _, i in ipairs(a) do + if type(i) == 'string' and i ~= '' then + nonempty = nonempty + 1 + elseif type(i) == 'table' then + nonempty = nonempty + get_nonempty(i) + end + end + a.nonempty = nonempty + return nonempty + end + + function compare_blanks(a, b) + local a_n, b_n = get_nonempty(a), get_nonempty(b) + if a_n == b_n then + return #a < #b + end + return a_n >= b_n + end +end + +local function unparse_elem(elem, res, force) + if elem.type == 'container' and not force then + local err = unparse_elem(elem, res, true) + if err then return err end + for _, e in ipairs(elem) do + local err = unparse_elem(e, res) + if err then return err end + end + return unparse_elem({type='container_end'}, res, true) + end + + local data = elements[elem.type] + if not data or (not force and elem.type == 'container_end') then + return nil, 'Unknown element "' .. tostring(elem.type) .. '".' + end + + local good, raw_elem + local possible_elems = {} + for i, template in ipairs(data) do + if type(template) == 'function' then + good, raw_elem = false, 'Unknown element.' + else + good, raw_elem = pcall(unparse_value, elem, template) + end + if good then + table.insert(raw_elem, 1, elem.type) + table.insert(possible_elems, raw_elem) + end + end + + -- Use the shortest element format that doesn't lose any information. + if good then + table.sort(possible_elems, compare_blanks) + table.insert(res, possible_elems[1]) + else + return 'Invalid element with type "' .. tostring(elem.type) + .. '": ' .. tostring(raw_elem) + end +end + +-- Convert a formspec AST back into a formspec. +-- Input: +-- { +-- { +-- type = "size", +-- w = 5, +-- h = 2, +-- }, +-- { +-- type = "button", +-- x = 0, +-- y = 0, +-- w = 5, +-- h = 1, +-- name = "name", +-- label = "Label", +-- }, +-- { +-- type = "image", +-- x = 0, +-- y = 1, +-- w = 1, +-- h = 1, +-- texture_name = "air.png", +-- } +-- } +-- Output: size[5,2,]button[0,0;5,1;name;Label]image[0,1;1,1;air.png] +function formspec_ast.unparse(spec) + local raw_spec = {} + for _, elem in ipairs(spec) do + local err = unparse_elem(elem, raw_spec) + if err then + return nil, err + end + end + + if spec.formspec_version and spec.formspec_version ~= 1 then + table.insert(raw_spec, 1, {'formspec_version', + tostring(spec.formspec_version)}) + end + return raw_unparse(raw_spec) +end + +-- Allow other mods to access raw_parse and raw_unparse. Note that these may +-- change or be removed at any time. +formspec_ast._raw_parse = raw_parse +formspec_ast._raw_unparse = raw_unparse + +-- Register custom elements +parse_mt = {} +function parse_mt:__index(key) + if key == '...' then + key = nil + end + + local func = types[key] + if func then + return function(obj) + if type(obj) == 'table' then + obj = table.concat(obj, ',') + end + return func(obj or '') + end + else + return function(obj) + error('Unknown element type: ' .. tostring(key)) + end + end +end + +-- Register custom formspec elements. +-- `parse_func` gets two parameters: `raw_elem` and `parse`. The parse table +-- is the same as the types table above, however unknown types raise an error. +-- The function should return either a single AST node or a list of multiple +-- nodes. +-- Multiple functions can be registered for one element. +function formspec_ast.register_element(name, parse_func) + assert(type(name) == 'string' and type(parse_func) == 'function') + if not elements[name] then + elements[name] = {} + end + local parse = {} + setmetatable(parse, parse_mt) + table.insert(elements[name], function(raw_elem) + local res = parse_func(raw_elem, parse) + if type(res) == 'table' and not res.type then + res.type = 'container' + res.x, res.y = 0, 0 + end + return res + end) +end diff --git a/elements.lua b/elements.lua new file mode 100644 index 0000000..14e94ca --- /dev/null +++ b/elements.lua @@ -0,0 +1,6 @@ +-- +-- Formspec elements list. Do not update this by hand, it is auto-generated +-- by make_elements.py. +-- + +return {["formspec_version"] = {{{"version", "number"}}}, ["size"] = {{{{"w", "number"}, {"h", "number"}, {"fixed_size", "boolean"}}}}, ["position"] = {{{{"x", "number"}, {"y", "number"}}}}, ["anchor"] = {{{{"x", "number"}, {"y", "number"}}}}, ["no_prepend"] = {{}}, ["real_coordinates"] = {{{"bool", "boolean"}}}, ["container"] = {{{{"x", "number"}, {"y", "number"}}}}, ["container_end"] = {{}}, ["list"] = {{{"inventory_location", "string"}, {"list_name", "string"}, {{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"", "null"}}, {{"inventory_location", "string"}, {"list_name", "string"}, {{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"starting_item_index", "number"}}}, ["listring"] = {{{"inventory_location", "string"}, {"list_name", "string"}}, {}}, ["listcolors"] = {{{"slot_bg_normal", "string"}, {"slot_bg_hover", "string"}, {"slot_border", "string"}, {"tooltip_bgcolor", "string"}, {"tooltip_fontcolor", "string"}}, {{"slot_bg_normal", "string"}, {"slot_bg_hover", "string"}, {"slot_border", "string"}}, {{"slot_bg_normal", "string"}, {"slot_bg_hover", "string"}}}, ["tooltip"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"tooltip_text", "string"}, {"bgcolor", "string"}, {"fontcolor", "string"}}, {{"gui_element_name", "string"}, {"tooltip_text", "string"}, {"bgcolor", "string"}, {"fontcolor", "string"}}}, ["image"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}}}, ["item_image"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"item_name", "string"}}}, ["bgcolor"] = {{{"color", "string"}, {"fullscreen", "boolean"}}}, ["background"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"auto_clip", "boolean"}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}}}, ["background9"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"auto_clip", "boolean"}, {{"middle_x", "number"}, {"middle_y", "number"}, {"middle_x2", "number"}, {"middle_y2", "number"}}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"auto_clip", "boolean"}, {{"middle_x", "number"}, {"middle_y", "number"}}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"auto_clip", "boolean"}, {{"middle_x", "number"}}}}, ["pwdfield"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {"label", "string"}}}, ["field"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {"label", "string"}, {"default", "string"}}, {{"name", "string"}, {"label", "string"}, {"default", "string"}}}, ["field_close_on_enter"] = {{{"name", "string"}, {"close_on_enter", "string"}}}, ["textarea"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {"label", "string"}, {"default", "string"}}}, ["label"] = {{{{"x", "number"}, {"y", "number"}}, {"label", "string"}}}, ["vertlabel"] = {{{{"x", "number"}, {"y", "number"}}, {"label", "string"}}}, ["button"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {"label", "string"}}}, ["image_button"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"name", "string"}, {"label", "string"}, {"noclip", "boolean"}, {"drawborder", "boolean"}, {"pressed_texture_name", "string"}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"name", "string"}, {"label", "string"}}}, ["item_image_button"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"item_name", "string"}, {"name", "string"}, {"label", "string"}}}, ["button_exit"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {"label", "string"}}}, ["image_button_exit"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"texture_name", "string"}, {"name", "string"}, {"label", "string"}}}, ["textlist"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {{{"listelem", "string"}, "..."}}, {"selected_idx", "number"}, {"transparent", "boolean"}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {{{"listelem", "string"}, "..."}}}}, ["tabheader"] = {{{{"x", "number"}, {"y", "number"}}, {"h", "number"}, {"name", "string"}, {{{"caption", "string"}, "..."}}, {"current_tab", "string"}, {"transparent", "boolean"}, {"draw_border", "boolean"}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {{{"caption", "string"}, "..."}}, {"current_tab", "string"}, {"transparent", "boolean"}, {"draw_border", "boolean"}}, {{{"x", "number"}, {"y", "number"}}, {"name", "string"}, {{{"caption", "string"}, "..."}}, {"current_tab", "string"}, {"transparent", "boolean"}, {"draw_border", "boolean"}}}, ["box"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"color", "string"}}}, ["dropdown"] = {{{{"x", "number"}, {"y", "number"}}, {"w", "number"}, {"name", "string"}, {{"item_1", "string"}, {"item_2", "string"}, {"...", "string"}, {"item_n", "string"}}, {"selected_idx", "number"}}, {{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {{"item_1", "string"}, {"item_2", "string"}, {"...", "string"}, {"item_n", "string"}}, {"selected_idx", "number"}}}, ["checkbox"] = {{{{"x", "number"}, {"y", "number"}}, {"name", "string"}, {"label", "string"}, {"selected", "boolean"}}}, ["scrollbar"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"orientation", "string"}, {"name", "string"}, {"value", "string"}}}, ["table"] = {{{{"x", "number"}, {"y", "number"}}, {{"w", "number"}, {"h", "number"}}, {"name", "string"}, {{{"cells", "string"}, "..."}}, {"selected_idx", "number"}}}, ["tableoptions"] = {{{{"opt", "table"}, "..."}}}, ["tablecolumns"] = {{{{{"type", "string"}, {{"opt", "table"}, "..."}}, "..."}}}, ["style"] = {{{"name", "string"}, {{"prop", "table"}, "..."}}}, ["style_type"] = {{{"elem_type", "string"}, {{"prop", "table"}, "..."}}}} \ No newline at end of file diff --git a/helpers.lua b/helpers.lua new file mode 100644 index 0000000..e6f0d9e --- /dev/null +++ b/helpers.lua @@ -0,0 +1,206 @@ +-- +-- formspec_ast: An abstract system tree for formspecs. +-- +-- This does not actually depend on Minetest and could probably run in +-- standalone Lua. +-- +-- The MIT License (MIT) +-- +-- Copyright © 2019 by luk3yx. +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to +-- deal in the Software without restriction, including without limitation the +-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +-- sell copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +-- IN THE SOFTWARE. +-- + +local formspec_ast, minetest = formspec_ast, formspec_ast.minetest + +-- Parses and unparses plain formspecs and just unparses AST trees. +function formspec_ast.interpret(spec, custom_handlers) + local ast = spec + if type(spec) == 'string' then + local err + ast, err = formspec_ast.parse(spec, custom_handlers) + if not ast then + return nil, err + end + end + return formspec_ast.unparse(ast) +end + +-- Returns an iterator over all nodes in a formspec AST, including ones in +-- containers. +function formspec_ast.walk(tree) + local parents = {} + local i = 1 + return function() + local res = tree[i] + while not res do + local n = table.remove(parents) + if not n then + return + end + tree, i = n[1], n[2] + res = tree[i] + end + i = i + 1 + + if res.type == 'container' then + table.insert(parents, {tree, i}) + tree = res + i = 1 + end + return res + end +end + +-- Similar to formspec_ast.walk(), however only returns nodes which have a type +-- of `node_type`. +function formspec_ast.find(tree, node_type) + local walk = formspec_ast.walk(tree) + return function() + local node + repeat + node = walk() + until node == nil or node.type == node_type + return node + end +end + +-- Returns the first element in the AST tree that has the given name. +function formspec_ast.get_element_by_name(tree, name) + for elem in formspec_ast.walk(tree) do + if elem.name == name then + return elem + end + end +end + +-- Returns a table/list/array of all elements in the AST tree that have the +-- given name. +function formspec_ast.get_elements_by_name(tree, name) + local res = {} + for elem in formspec_ast.walk(tree) do + if elem.name == name then + table.insert(res, elem) + end + end + return res +end + +-- Offsets all elements in an element list. +function formspec_ast.apply_offset(elems, x, y) + x, y = x or 0, y or 0 + for _, elem in ipairs(elems) do + if type(elem.x) == 'number' and type(elem.y) == 'number' then + elem.x = elem.x + x + elem.y = elem.y + y + end + end +end + +-- Removes container elements and fixes nodes inside containers. +function formspec_ast.flatten(tree) + local res = {formspec_version=tree.formspec_version} + for elem in formspec_ast.walk(table.copy(tree)) do + if elem.type == 'container' then + formspec_ast.apply_offset(elem, elem.x, elem.y) + else + table.insert(res, elem) + end + end + return res +end + +-- Similar to minetest.show_formspec, however is passed through +-- formspec_ast.interpret first and will return an error message if the +-- formspec could not be parsed. +function formspec_ast.show_formspec(player, formname, formspec) + if minetest.is_player(player) then + player = player:get_player_name() + end + if type(player) ~= 'string' or player == '' then + return 'No such player!' + end + + local formspec, err = formspec_ast.interpret(formspec) + if formspec then + minetest.show_formspec(player, formname, formspec) + else + minetest.log('warning', 'formspec_ast.show_formspec(): ' .. + tostring(err)) + return err + end +end + +-- Centered labels +-- Credit to https://github.com/v-rob/minetest_formspec_game for the click +-- animation workaround. +-- size[5,2]formspec_ast:centered_label[0,0;5,1;Centered label] +formspec_ast.register_element('formspec_ast:centered_label', function(raw, + parse) + -- Create a container + return { + type = 'container', + x = parse.number(raw[1][1]), + y = parse.number(raw[1][2]), + + -- Add a background-less image button with the text. + { + type = 'image_button', + x = 0, + y = 0, + w = parse.number(raw[2][1]), + h = parse.number(raw[2][2]), + texture_name = '', + name = '', + label = parse.string(raw[3]), + noclip = true, + drawborder = false, + pressed_texture_name = '', + }, + + -- Add another background-less image button to hack around the click + -- animation. + { + type = 'image_button', + x = 0, + y = 0, + w = parse.number(raw[2][1]), + h = parse.number(raw[2][2]), + texture_name = '', + name = '', + label = '', + noclip = true, + drawborder = false, + pressed_texture_name = '', + }, + } +end) + +-- Add a formspec element to crash clients +formspec_ast.register_element('formspec_ast:crash', function(raw, parse) + return { + type = 'list', + inventory_location = '___die', + list_name = 'crash', + x = 0, + y = 0, + w = 0, + h = 0, + } +end) diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..ac71537 --- /dev/null +++ b/init.lua @@ -0,0 +1,60 @@ +-- +-- formspec_ast: An abstract system tree for formspecs. +-- +-- This does not actually depend on Minetest and could probably run in +-- standalone Lua. +-- +-- The MIT License (MIT) +-- +-- Copyright © 2019 by luk3yx. +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to +-- deal in the Software without restriction, including without limitation the +-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +-- sell copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +-- IN THE SOFTWARE. +-- + +formspec_ast = {} + +local minetest = minetest +local modpath + +if minetest then + -- Running inside Minetest. + modpath = minetest.get_modpath('formspec_ast') + is_yes = minetest.is_yes + assert(minetest.get_current_modname() == 'formspec_ast', + 'This mod must be called formspec_ast!') +else + -- Probably running outside Minetest. + modpath = '.' + minetest = core or {} + function minetest.is_yes(str) + str = str:lower() + return str == 'true' or str == 'yes' + end + function string.trim(str) + return str:gsub("^%s*(.-)%s*$", "%1") + end +end + +formspec_ast.modpath, formspec_ast.minetest = modpath, minetest + +dofile(modpath .. '/core.lua') +dofile(modpath .. '/helpers.lua') +dofile(modpath .. '/safety.lua') + +formspec_ast.modpath, formspec_ast.minetest = nil, nil diff --git a/lua_dump.py b/lua_dump.py new file mode 100644 index 0000000..54a2d61 --- /dev/null +++ b/lua_dump.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# +# Converts Python objects into Lua ones. This allows more things to be used as +# keys than JSON. +# +# The MIT License (MIT) +# +# Copyright © 2019 by luk3yx. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# + +from decimal import Decimal + +def _escape_string(x): + yield '"' + for char in x: + if char == 0x22: # " + yield '\"' + elif char == 0x5c: + yield r'\\' + elif 0x7f > char > 0x19: + yield chr(char) + else: + yield '\\' + str(char).zfill(3) + yield '"' + +class _PartialTypeError(TypeError): + def __str__(self): + return 'Object of type ' + repr(type(self.args[0]).__name__) + \ + ' is not Lua serializable.' + +def _dump(obj): + if isinstance(obj, set): + obj = dict.fromkeys(obj, True) + + if isinstance(obj, dict): + res = [] + for k, v in obj.items(): + res.append('[' + _dump(k) + '] = ' + _dump(v)) + return '{' + ', '.join(res) + '}' + + if isinstance(obj, (tuple, list)): + return '{' + ', '.join(map(_dump, obj)) + '}' + + if isinstance(obj, bool): + return 'true' if obj else 'false' + + if isinstance(obj, (int, float, Decimal)): + return str(obj) + + if isinstance(obj, str): + obj = obj.encode('utf-8', 'replace') + + if isinstance(obj, bytes): + return ''.join(_escape_string(obj)) + + if obj is None: + return 'nil' + + raise _PartialTypeError(obj) + +def dump(obj): + """ + Similar to serialize(), however doesn't prepend return. + """ + + try: + return _dump(obj) + except _PartialTypeError as e: + msg = str(e) + + # Clean tracebacks + raise TypeError(msg) + +def serialize(obj): + """ + Serialize an object into valid Lua code. This will raise a TypeError if the + object cannot be serialized into lua. + """ + + try: + return 'return ' + _dump(obj) + except _PartialTypeError as e: + msg = str(e) + + # Clean tracebacks + raise TypeError(msg) diff --git a/make_elements.py b/make_elements.py new file mode 100755 index 0000000..2e91fba --- /dev/null +++ b/make_elements.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# +# A primitive script to parse lua_api.txt for formspec elements. +# + +import copy, lua_dump, os, urllib.request + +def _make_known(**kwargs): + known = {} + for k, v in kwargs.items(): + for i in v: + known[i] = k + return known + +_known = _make_known( + number=('x', 'y', 'w', 'h', 'selected_idx', 'version', + 'starting_item_index'), + boolean=('auto_clip', 'fixed_size', 'transparent', 'draw_border', 'bool', + 'fullscreen', 'noclip', 'drawborder', 'selected'), + table=('param', 'opt', 'prop'), + null=('',), +) + +def _get_name(n): + if not isinstance(n, tuple) or n[1] == '...': + return '...' + return n[0][:-1].rsplit('_', 1)[0].rstrip('_') + +_aliases = { + 'type': 'elem_type', + 'cell': 'cells', +} + +def _fix_param(param): + if isinstance(param, str): + if ',' not in param: + param = param.lower().strip().strip('<>').replace(' ', '_') + param = _aliases.get(param, param) + return (param, _known.get(param, 'string')) + param = param.split(',') + + res = [] + for p in param: + if p != '...' or not res: + res.append(_fix_param(p)) + continue + + last = res.pop() + # Workaround + if res and last and isinstance(last, list) and \ + last[0][0].endswith('2') and isinstance(res[-1], list) and \ + res[-1] and res[-1][0][0].endswith('1'): + last = res.pop() + last[0] = (last[0][0][:-2], last[0][1]) + + name = _get_name(last) + if name == '...': + res.append((last, '...')) + else: + while res and _get_name(res[-1]) == name: + res.pop() + res.append((_fix_param(name), '...')) + break + + return res + +_hooks = {} +def hook(name): + def add_hook(func): + _hooks[name] = func + return func + return add_hook + +# Fix background9 +@hook('background9') +def _background9_hook(params): + assert params[-1] == ('middle', 'string') + params[-1] = param = [] + param.append(('middle_x', 'number')) + yield params + param.append(('middle_y', 'number')) + yield params + param.append(('middle_x2', 'number')) + param.append(('middle_y2', 'number')) + yield params + +def _raw_parse(data): + data = data.split('\nElements\n--------\n', 1)[-1].split('\n----', 1)[0] + for line in data.split('\n'): + if not line.startswith('### `') or not line.endswith('`'): + continue + + elem = line[5:-2] + name, params = elem.split('[', 1) + if params: + params = _fix_param(params.split(';')) + else: + params = [] + + if name in _hooks: + for p in reversed(tuple(map(copy.deepcopy, _hooks[name](params)))): + yield name, p + else: + yield name, params + +def parse(data): + """ + Returns a dict: + { + 'element_name': [ + ['param1', 'param2'], + ['alternate_params'], + ] + } + """ + res = {} + for k, v in _raw_parse(data): + if k not in res: + res[k] = [] + res[k].append(v) + + for v in res.values(): + v.sort(key=len, reverse=True) + + return res + +URL = 'https://github.com/minetest/minetest/raw/master/doc/lua_api.txt' +def fetch_and_parse(*, url=URL): + with urllib.request.urlopen(url) as f: + raw = f.read() + return parse(raw.decode('utf-8', 'replace')) + +_comment = """ +-- +-- Formspec elements list. Do not update this by hand, it is auto-generated +-- by make_elements.py. +-- + +""" + +def main(): + dirname = os.path.dirname(__file__) + filename = os.path.join(dirname, 'elements.lua') + print('Writing to ' + filename + '...') + with open(filename, 'w') as f: + f.write(_comment.lstrip()) + f.write(lua_dump.serialize(fetch_and_parse())) + # elems = fetch_and_parse() + # for elem in sorted(elems): + # for def_ in elems[elem]: + # f.write('formspec_ast.register_element({}, {})\n'.format( + # lua_dump.dump(elem), lua_dump.dump(def_) + # )) + print('Done.') + +if __name__ == '__main__': + main() diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..ea5d0cd --- /dev/null +++ b/mod.conf @@ -0,0 +1,2 @@ +name = formspec_ast +description = A mod library to help other mods interpret formspecs. diff --git a/safety.lua b/safety.lua new file mode 100644 index 0000000..003ae63 --- /dev/null +++ b/safety.lua @@ -0,0 +1,196 @@ +-- +-- formspec_ast: An abstract system tree for formspecs. +-- +-- This verifies that formspecs from untrusted sources are safe(-ish) to +-- display, provided they are passed through formspec_ast.interpret. +-- +-- The MIT License (MIT) +-- +-- Copyright © 2019 by luk3yx. +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to +-- deal in the Software without restriction, including without limitation the +-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +-- sell copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +-- IN THE SOFTWARE. +-- + +-- Similar to ast.walk(), however returns {} and then exits if walk() would +-- crash. Use this for untrusted formspecs, otherwise use walk() for speed. +local function safe_walk(tree) + local walk = formspec_ast.walk(tree) + local seen = {} + return function() + if not walk or not seen then return end + + local good, msg = pcall(walk) + if good and (type(msg) == 'table' or msg == nil) and not seen[msg] then + if msg then + seen[msg] = true + end + return msg + else + return {} + end + end +end + +-- Similar to ast.flatten(), however removes unsafe elements. +local function safe_flatten(tree) + local res = {formspec_version = 1} + if tree.formspec_version == 2 then + res.formspec_version = 2 + end + for elem in safe_walk(table.copy(tree)) do + if elem.type == 'container' then + if type(elem.x) == 'number' and type(elem.y) == 'number' then + formspec_ast.apply_offset(elem, elem.x, elem.y) + end + elseif elem.type then + table.insert(res, elem) + end + end + return res +end + +local ensure = {} + +function ensure.string(obj) + if obj == nil then + return '' + end + return tostring(obj) +end + +function ensure.number(obj, max, min) + local res = tonumber(obj) + assert(res ~= nil and res == res) + assert(res <= (max or 100) and res >= (min or 0)) + return res +end + +function ensure.integer(obj) + return math.floor(ensure.number(obj)) +end + +local validate +local function validate_elem(obj) + local template = validate[obj.type] + assert(type(template) == 'table') + for k, v in pairs(obj) do + local func + if k == 'type' then + func = ensure.string + else + local type_ = template[k] + if type(type_) == 'string' then + if type_:sub(#type_) == '?' then + type_ = type_:sub(1, #type_ - 1) + end + func = ensure[type_] + elseif type(type_) == 'function' then + func = type_ + end + end + + if func then + obj[k] = func(v) + else + obj[k] = nil + end + end + + for k, v in pairs(template) do + if type(v) ~= 'string' or v:sub(#v) ~= '?' then + assert(obj[k] ~= nil, k .. ' does not exist!') + end + end +end + +validate = { + size = {w = 'number', h = 'number'}, + label = {x = 'number', y = 'number', label = 'string'}, + image = {x = 'number', y = 'number', w = 'number', h = 'number', + texture_name = 'string'}, + button = {x = 'number', y = 'number', w = 'number', h = 'number', + name = 'string', label = 'string'}, + field = {x = 'number', y = 'number', w = 'number', h = 'number', + name = 'string', label = 'string', default = 'string'}, + pwdfield = {x = 'number', y = 'number', w = 'number', h = 'number', + name = 'string', label = 'string'}, + field_close_on_enter = {name = 'string', close_on_enter = 'string'}, + textarea = {x = 'number', y = 'number', w = 'number', h = 'number', + name = 'string', label = 'string', default = 'string'}, + dropdown = { + x = 'number', y = 'number', w = 'number', name = 'string', + items = function(items) + assert(type(items) == 'list') + for k, v in pairs(items) do + assert(type(k) == 'number' and type(v) == 'string') + end + end, + selected_idx = 'integer', + }, + checkbox = {x = 'number', y = 'number', name = 'string', label = 'string', + selected = 'string'}, + box = {x = 'number', y = 'number', w = 'number', h = 'number', + color = 'string'}, + + list = { + inventory_location = function(location) + assert(location == 'current_node' or location == 'current_player') + return location + end, + list_name = 'string', x = 'number', y = 'number', w = 'number', + h = 'number', starting_item_index = 'number?', + }, + listring = {}, +} + +validate.vertlabel = validate.label +validate.button_exit = validate.button +validate.image_button = validate.button +validate.image_button_exit = validate.button +validate.item_image_button = validate.button + +-- Ensure that an AST tree is safe to display. The resulting tree will be +-- flattened for simplicity. +function formspec_ast.safe_parse(tree, custom_handlers) + if type(tree) == 'string' then + tree = formspec_ast.parse(tree, custom_handlers) + end + + if type(tree) ~= 'table' then + return {} + end + + -- Flatten the tree and remove objects that can't possibly be elements. + tree = safe_flatten(tree) + + -- Iterate over the tree and add valid elements to a new table. + local res = {formspec_version = tree.formspec_version} + for i, elem in ipairs(tree) do + local good, msg = pcall(validate_elem, elem) + if good then + res[#res + 1] = elem + end + end + + return res +end + +function formspec_ast.safe_interpret(tree) + return formspec_ast.unparse(formspec_ast.safe_parse(tree)) +end