formspec_ast/core.lua

571 lines
17 KiB
Lua

--
-- 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 #inner > 0 then
table.insert(inner, i)
table.insert(elem, inner)
else
table.insert(elem, i)
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.fullscreen(param)
if param == 'both' or param == 'neither' then
return param
end
return types.boolean(param)
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_l > 0 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 _, 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, true
else
table.insert(elem, 1, elem.type)
return nil, 'Invalid element "' .. raw_unparse({elem}) .. '": ' ..
tostring(ast_elem)
end
end
local good, ast_elem
for _, template in ipairs(data) do
local custom_element = type(template) == 'function'
if custom_element 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, custom_element
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' or
ast_elem.type == 'scroll_container') and not err then
table.insert(containers, container)
container = ast_elem
elseif ast_elem.type == 'end' or ast_elem.type == 'container_end' or
ast_elem.type == 'scroll_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]]
if value == nil then return end
for _, 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 _, 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)
if a.nonempty then
return a.nonempty, a.strings, a.total_length
end
local nonempty, strings, total_length = 0, 0, #a
for _, i in ipairs(a) do
if type(i) == 'string' and i ~= '' then
nonempty = nonempty + 1
strings = strings + 1
elseif type(i) == 'table' then
local n, s, t = get_nonempty(i)
nonempty = nonempty + n
strings = strings + s
total_length = total_length + t
end
end
a.nonempty, a.strings, a.total_length = nonempty, strings, total_length
return nonempty, strings, total_length
end
function compare_blanks(a, b)
local a_n, a_strings, a_l = get_nonempty(a)
local b_n, b_strings, b_l = get_nonempty(b)
if a_n == b_n then
if a_l == b_l then
-- Prefer elements with less tables
return a_strings > b_strings
else
return a_l < b_l
end
end
return a_n >= b_n
end
end
local function unparse_elem(elem, res, force)
if (elem.type == 'container' or
elem.type == 'scroll_container') and not force then
local err = unparse_elem(elem, res, true)
if err then return err end
for _, e in ipairs(elem) do
err = unparse_elem(e, res)
if err then return err end
end
return unparse_elem({type=elem.type .. '_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 _, 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(_)
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