commit b2399d68257540fc30f556d823929d728d8b1cb6 Author: luk3yx Date: Sun Oct 20 14:57:16 2019 +1300 Initial commit 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