Initial commit

This commit is contained in:
luk3yx 2019-10-20 14:57:16 +13:00
commit b2399d6825
11 changed files with 1485 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
test.lua

22
LICENSE.md Normal file
View File

@ -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.

182
README.md Normal file
View File

@ -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]
```

548
core.lua Normal file
View File

@ -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

6
elements.lua Normal file

File diff suppressed because one or more lines are too long

206
helpers.lua Normal file
View File

@ -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)

60
init.lua Normal file
View File

@ -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

104
lua_dump.py Normal file
View File

@ -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)

157
make_elements.py Executable file
View File

@ -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()

2
mod.conf Normal file
View File

@ -0,0 +1,2 @@
name = formspec_ast
description = A mod library to help other mods interpret formspecs.

196
safety.lua Normal file
View File

@ -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